@pjmendonca/devflow 1.19.0 → 1.20.0
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/.claude/skills/dashboard/SKILL.md +118 -0
- package/CHANGELOG.md +21 -0
- package/README.md +2 -2
- package/bin/devflow-dashboard.js +10 -0
- package/bin/devflow-swarm.js +11 -0
- package/bin/devflow.js +2 -0
- package/package.json +3 -1
- package/tooling/.automation/memory/knowledge/kg_integration-test.json +85 -1
- package/tooling/.automation/memory/knowledge/kg_test-story.json +284 -2
- package/tooling/.automation/memory/shared/shared_integration-test.json +25 -1
- package/tooling/.automation/memory/shared/shared_test-story.json +73 -1
- package/tooling/.automation/memory/shared/shared_test.json +157 -1
- package/tooling/.automation/memory/shared/shared_validation-check.json +27 -1
- package/tooling/.automation/validation/history/2026-01-16_val_0b81ec2f.json +41 -0
- package/tooling/.automation/validation/history/2026-01-16_val_26c18e64.json +32 -0
- package/tooling/.automation/validation/history/2026-01-16_val_32af0152.json +32 -0
- package/tooling/.automation/validation/history/2026-01-16_val_353d1569.json +32 -0
- package/tooling/.automation/validation/history/2026-01-16_val_39e3c143.json +59 -0
- package/tooling/.automation/validation/history/2026-01-16_val_77fb42e4.json +32 -0
- package/tooling/.automation/validation/history/2026-01-16_val_a0752656.json +41 -0
- package/tooling/.automation/validation/history/2026-01-16_val_a29213b0.json +41 -0
- package/tooling/.automation/validation/history/2026-01-16_val_a9375d4c.json +32 -0
- package/tooling/.automation/validation/history/2026-01-16_val_c147bbdf.json +32 -0
- package/tooling/.automation/validation/history/2026-01-16_val_d06ccf8d.json +32 -0
- package/tooling/.automation/validation/history/2026-01-16_val_d6a80295.json +59 -0
- package/tooling/.automation/validation/history/2026-01-16_val_dce5005d.json +41 -0
- package/tooling/.automation/validation/history/2026-01-16_val_e53b3a63.json +32 -0
- package/tooling/scripts/lib/__init__.py +1 -1
- package/tooling/scripts/lib/cost_display.py +7 -1
- package/tooling/scripts/lib/pair_programming.py +6 -4
- package/tooling/scripts/lib/swarm_orchestrator.py +13 -11
- package/tooling/scripts/live_dashboard.py +832 -0
- package/tooling/scripts/new-doc.py +1 -1
- package/tooling/scripts/run-collab.py +2 -2
- package/tooling/scripts/run-story.py +21 -9
- package/tooling/scripts/setup-checkpoint-service.py +1 -1
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Live Dashboard - Real-time status display for Devflow.
|
|
4
|
+
|
|
5
|
+
Provides a rich, auto-updating terminal dashboard showing:
|
|
6
|
+
- Current agent and activity
|
|
7
|
+
- Context window usage with visual progress bar
|
|
8
|
+
- Cost tracking with budget visualization
|
|
9
|
+
- Token history and trends
|
|
10
|
+
- Session statistics
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python live_dashboard.py [story-key] [options]
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--refresh SECONDS Refresh interval (default: 0.5)
|
|
17
|
+
--compact Use compact single-line mode
|
|
18
|
+
--no-color Disable colors
|
|
19
|
+
--width WIDTH Dashboard width (default: 70)
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
python live_dashboard.py 3-5
|
|
23
|
+
python live_dashboard.py 3-5 --refresh 0.25
|
|
24
|
+
python live_dashboard.py --compact
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import signal
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
# Add lib directory for imports
|
|
37
|
+
SCRIPT_DIR = Path(__file__).parent
|
|
38
|
+
sys.path.insert(0, str(SCRIPT_DIR / "lib"))
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from lib.platform import IS_WINDOWS
|
|
42
|
+
except ImportError:
|
|
43
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
44
|
+
|
|
45
|
+
# Project paths
|
|
46
|
+
PROJECT_ROOT = SCRIPT_DIR.parent.parent
|
|
47
|
+
CONTEXT_STATE_DIR = PROJECT_ROOT / "tooling" / ".automation" / "context"
|
|
48
|
+
COST_SESSIONS_DIR = PROJECT_ROOT / "tooling" / ".automation" / "costs" / "sessions"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ANSI escape codes
|
|
52
|
+
class Colors:
|
|
53
|
+
"""ANSI color codes for terminal output."""
|
|
54
|
+
|
|
55
|
+
# Basic colors
|
|
56
|
+
BLACK = "\033[30m"
|
|
57
|
+
RED = "\033[31m"
|
|
58
|
+
GREEN = "\033[32m"
|
|
59
|
+
YELLOW = "\033[33m"
|
|
60
|
+
BLUE = "\033[34m"
|
|
61
|
+
MAGENTA = "\033[35m"
|
|
62
|
+
CYAN = "\033[36m"
|
|
63
|
+
WHITE = "\033[37m"
|
|
64
|
+
|
|
65
|
+
# Bright colors
|
|
66
|
+
BRIGHT_BLACK = "\033[90m"
|
|
67
|
+
BRIGHT_RED = "\033[91m"
|
|
68
|
+
BRIGHT_GREEN = "\033[92m"
|
|
69
|
+
BRIGHT_YELLOW = "\033[93m"
|
|
70
|
+
BRIGHT_BLUE = "\033[94m"
|
|
71
|
+
BRIGHT_MAGENTA = "\033[95m"
|
|
72
|
+
BRIGHT_CYAN = "\033[96m"
|
|
73
|
+
BRIGHT_WHITE = "\033[97m"
|
|
74
|
+
|
|
75
|
+
# Styles
|
|
76
|
+
BOLD = "\033[1m"
|
|
77
|
+
DIM = "\033[2m"
|
|
78
|
+
ITALIC = "\033[3m"
|
|
79
|
+
UNDERLINE = "\033[4m"
|
|
80
|
+
BLINK = "\033[5m"
|
|
81
|
+
REVERSE = "\033[7m"
|
|
82
|
+
|
|
83
|
+
# Background
|
|
84
|
+
BG_RED = "\033[41m"
|
|
85
|
+
BG_GREEN = "\033[42m"
|
|
86
|
+
BG_YELLOW = "\033[43m"
|
|
87
|
+
BG_BLUE = "\033[44m"
|
|
88
|
+
BG_MAGENTA = "\033[45m"
|
|
89
|
+
BG_CYAN = "\033[46m"
|
|
90
|
+
|
|
91
|
+
# Reset
|
|
92
|
+
RESET = "\033[0m"
|
|
93
|
+
END = "\033[0m"
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def disable(cls):
|
|
97
|
+
"""Disable all colors."""
|
|
98
|
+
for attr in dir(cls):
|
|
99
|
+
if not attr.startswith("_") and attr != "disable":
|
|
100
|
+
setattr(cls, attr, "")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Box drawing characters
|
|
104
|
+
class Box:
|
|
105
|
+
"""Box drawing characters for dashboard frames."""
|
|
106
|
+
|
|
107
|
+
# Single line
|
|
108
|
+
TOP_LEFT = "╔"
|
|
109
|
+
TOP_RIGHT = "╗"
|
|
110
|
+
BOTTOM_LEFT = "╚"
|
|
111
|
+
BOTTOM_RIGHT = "╝"
|
|
112
|
+
HORIZONTAL = "═"
|
|
113
|
+
VERTICAL = "║"
|
|
114
|
+
T_LEFT = "╠"
|
|
115
|
+
T_RIGHT = "╣"
|
|
116
|
+
T_TOP = "╦"
|
|
117
|
+
T_BOTTOM = "╩"
|
|
118
|
+
CROSS = "╬"
|
|
119
|
+
|
|
120
|
+
# Progress bar characters
|
|
121
|
+
BLOCK_FULL = "█"
|
|
122
|
+
BLOCK_7_8 = "▉"
|
|
123
|
+
BLOCK_3_4 = "▊"
|
|
124
|
+
BLOCK_5_8 = "▋"
|
|
125
|
+
BLOCK_HALF = "▌"
|
|
126
|
+
BLOCK_3_8 = "▍"
|
|
127
|
+
BLOCK_1_4 = "▎"
|
|
128
|
+
BLOCK_1_8 = "▏"
|
|
129
|
+
BLOCK_EMPTY = "░"
|
|
130
|
+
|
|
131
|
+
# Trend indicators
|
|
132
|
+
ARROW_UP = "▲"
|
|
133
|
+
ARROW_DOWN = "▼"
|
|
134
|
+
ARROW_RIGHT = "▶"
|
|
135
|
+
ARROW_FLAT = "─"
|
|
136
|
+
|
|
137
|
+
# Status indicators
|
|
138
|
+
DOT_FILLED = "●"
|
|
139
|
+
DOT_EMPTY = "○"
|
|
140
|
+
CHECK = "✓"
|
|
141
|
+
CROSS_MARK = "✗"
|
|
142
|
+
SPINNER = ["◐", "◓", "◑", "◒"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class DashboardState:
|
|
146
|
+
"""Holds the current dashboard state loaded from files."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, story_key: str = "default"):
|
|
149
|
+
self.story_key = story_key
|
|
150
|
+
self.last_update = datetime.now()
|
|
151
|
+
|
|
152
|
+
# Context state
|
|
153
|
+
self.context_percent = 0.0
|
|
154
|
+
self.context_tokens = 0
|
|
155
|
+
self.context_window = 200000
|
|
156
|
+
self.tokens_remaining = 200000
|
|
157
|
+
self.exchanges_remaining = 40
|
|
158
|
+
|
|
159
|
+
# Activity state
|
|
160
|
+
self.current_agent = None
|
|
161
|
+
self.current_phase = None
|
|
162
|
+
self.current_task = None
|
|
163
|
+
self.phase_start_time = None
|
|
164
|
+
self.phases_completed = 0
|
|
165
|
+
self.total_phases = 0
|
|
166
|
+
|
|
167
|
+
# Cost state
|
|
168
|
+
self.cost_usd = 0.0
|
|
169
|
+
self.budget_usd = 15.0
|
|
170
|
+
self.cost_percent = 0.0
|
|
171
|
+
self.input_tokens = 0
|
|
172
|
+
self.output_tokens = 0
|
|
173
|
+
|
|
174
|
+
# History
|
|
175
|
+
self.token_history = []
|
|
176
|
+
self.session_start = datetime.now()
|
|
177
|
+
|
|
178
|
+
# Status
|
|
179
|
+
self.is_active = False
|
|
180
|
+
self.last_file_update = None
|
|
181
|
+
|
|
182
|
+
def load_context_state(self):
|
|
183
|
+
"""Load context state from file."""
|
|
184
|
+
state_file = CONTEXT_STATE_DIR / f"context_{self.story_key}.json"
|
|
185
|
+
if state_file.exists():
|
|
186
|
+
try:
|
|
187
|
+
mtime = datetime.fromtimestamp(state_file.stat().st_mtime)
|
|
188
|
+
self.last_file_update = mtime
|
|
189
|
+
|
|
190
|
+
with open(state_file) as f:
|
|
191
|
+
data = json.load(f)
|
|
192
|
+
|
|
193
|
+
self.context_window = data.get("context_window", 200000)
|
|
194
|
+
self.context_tokens = data.get("estimated_context_tokens", 0)
|
|
195
|
+
self.context_percent = (
|
|
196
|
+
(self.context_tokens / self.context_window * 100)
|
|
197
|
+
if self.context_window > 0
|
|
198
|
+
else 0
|
|
199
|
+
)
|
|
200
|
+
self.tokens_remaining = max(0, self.context_window - self.context_tokens)
|
|
201
|
+
self.exchanges_remaining = self.tokens_remaining // 5000
|
|
202
|
+
|
|
203
|
+
self.input_tokens = data.get("total_input_tokens", 0)
|
|
204
|
+
self.output_tokens = data.get("total_output_tokens", 0)
|
|
205
|
+
self.token_history = data.get("token_history", [])[-10:]
|
|
206
|
+
|
|
207
|
+
if data.get("session_start"):
|
|
208
|
+
try:
|
|
209
|
+
self.session_start = datetime.fromisoformat(data["session_start"])
|
|
210
|
+
except (ValueError, TypeError):
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
# Check if recently updated (within last 30 seconds)
|
|
214
|
+
if data.get("last_update"):
|
|
215
|
+
try:
|
|
216
|
+
last = datetime.fromisoformat(data["last_update"])
|
|
217
|
+
self.is_active = (datetime.now() - last).total_seconds() < 30
|
|
218
|
+
except (ValueError, TypeError):
|
|
219
|
+
self.is_active = False
|
|
220
|
+
|
|
221
|
+
except (OSError, json.JSONDecodeError):
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
def load_cost_state(self):
|
|
225
|
+
"""Load cost state from session files."""
|
|
226
|
+
if not COST_SESSIONS_DIR.exists():
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
# Find most recent session file for this story
|
|
230
|
+
session_files = list(COST_SESSIONS_DIR.glob(f"*{self.story_key}*.json"))
|
|
231
|
+
if not session_files:
|
|
232
|
+
# Try to find any recent session
|
|
233
|
+
session_files = list(COST_SESSIONS_DIR.glob("*.json"))
|
|
234
|
+
|
|
235
|
+
if session_files:
|
|
236
|
+
# Get most recent
|
|
237
|
+
latest = max(session_files, key=lambda p: p.stat().st_mtime)
|
|
238
|
+
try:
|
|
239
|
+
with open(latest) as f:
|
|
240
|
+
data = json.load(f)
|
|
241
|
+
|
|
242
|
+
self.cost_usd = data.get("total_cost_usd", 0.0)
|
|
243
|
+
self.budget_usd = data.get("budget_limit_usd", 15.0)
|
|
244
|
+
self.cost_percent = (
|
|
245
|
+
(self.cost_usd / self.budget_usd * 100) if self.budget_usd > 0 else 0
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
except (OSError, json.JSONDecodeError):
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
def load_activity_state(self):
|
|
252
|
+
"""Load current activity from context state."""
|
|
253
|
+
state_file = CONTEXT_STATE_DIR / f"context_{self.story_key}.json"
|
|
254
|
+
if state_file.exists():
|
|
255
|
+
try:
|
|
256
|
+
with open(state_file) as f:
|
|
257
|
+
data = json.load(f)
|
|
258
|
+
|
|
259
|
+
self.current_agent = data.get("current_agent")
|
|
260
|
+
self.current_phase = data.get("current_phase")
|
|
261
|
+
self.current_task = data.get("current_task")
|
|
262
|
+
self.phases_completed = data.get("phases_completed", 0)
|
|
263
|
+
self.total_phases = data.get("total_phases", 0)
|
|
264
|
+
|
|
265
|
+
if data.get("phase_start_time"):
|
|
266
|
+
try:
|
|
267
|
+
self.phase_start_time = datetime.fromisoformat(data["phase_start_time"])
|
|
268
|
+
except (ValueError, TypeError):
|
|
269
|
+
self.phase_start_time = None
|
|
270
|
+
|
|
271
|
+
except (OSError, json.JSONDecodeError):
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
def refresh(self):
|
|
275
|
+
"""Refresh all state from files."""
|
|
276
|
+
self.load_context_state()
|
|
277
|
+
self.load_cost_state()
|
|
278
|
+
self.load_activity_state()
|
|
279
|
+
self.last_update = datetime.now()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class LiveDashboard:
|
|
283
|
+
"""
|
|
284
|
+
Rich terminal dashboard with real-time updates.
|
|
285
|
+
|
|
286
|
+
Displays context, cost, activity, and history in a visual format.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
AGENT_COLORS = {
|
|
290
|
+
"SM": Colors.CYAN,
|
|
291
|
+
"DEV": Colors.GREEN,
|
|
292
|
+
"REVIEWER": Colors.MAGENTA,
|
|
293
|
+
"ARCHITECT": Colors.BLUE,
|
|
294
|
+
"BA": Colors.YELLOW,
|
|
295
|
+
"PM": Colors.CYAN,
|
|
296
|
+
"MAINTAINER": Colors.YELLOW,
|
|
297
|
+
"SECURITY": Colors.RED,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
def __init__(
|
|
301
|
+
self,
|
|
302
|
+
story_key: str = "default",
|
|
303
|
+
width: int = 70,
|
|
304
|
+
refresh_interval: float = 0.5,
|
|
305
|
+
compact: bool = False,
|
|
306
|
+
):
|
|
307
|
+
self.story_key = story_key
|
|
308
|
+
self.width = width
|
|
309
|
+
self.refresh_interval = refresh_interval
|
|
310
|
+
self.compact = compact
|
|
311
|
+
self.state = DashboardState(story_key)
|
|
312
|
+
self.running = False
|
|
313
|
+
self.spinner_index = 0
|
|
314
|
+
self.frame_count = 0
|
|
315
|
+
|
|
316
|
+
def _clear_screen(self):
|
|
317
|
+
"""Clear terminal screen."""
|
|
318
|
+
if IS_WINDOWS:
|
|
319
|
+
os.system("cls")
|
|
320
|
+
else:
|
|
321
|
+
# Use ANSI escape to clear and move cursor to top
|
|
322
|
+
print("\033[2J\033[H", end="")
|
|
323
|
+
|
|
324
|
+
def _move_cursor_home(self):
|
|
325
|
+
"""Move cursor to top-left without clearing."""
|
|
326
|
+
print("\033[H", end="")
|
|
327
|
+
|
|
328
|
+
def _hide_cursor(self):
|
|
329
|
+
"""Hide terminal cursor."""
|
|
330
|
+
print("\033[?25l", end="")
|
|
331
|
+
|
|
332
|
+
def _show_cursor(self):
|
|
333
|
+
"""Show terminal cursor."""
|
|
334
|
+
print("\033[?25h", end="")
|
|
335
|
+
|
|
336
|
+
def _draw_line(self, left: str, fill: str, right: str, content: str = "") -> str:
|
|
337
|
+
"""Draw a line with borders and optional content."""
|
|
338
|
+
inner_width = self.width - 2
|
|
339
|
+
if content:
|
|
340
|
+
padding = inner_width - len(self._strip_ansi(content))
|
|
341
|
+
if padding < 0:
|
|
342
|
+
content = content[:inner_width]
|
|
343
|
+
padding = 0
|
|
344
|
+
return f"{left}{content}{' ' * padding}{right}"
|
|
345
|
+
return f"{left}{fill * inner_width}{right}"
|
|
346
|
+
|
|
347
|
+
def _strip_ansi(self, text: str) -> str:
|
|
348
|
+
"""Remove ANSI escape codes for length calculation."""
|
|
349
|
+
import re
|
|
350
|
+
|
|
351
|
+
return re.sub(r"\033\[[0-9;]*m", "", text)
|
|
352
|
+
|
|
353
|
+
def _pad_content(self, content: str, width: int, align: str = "left") -> str:
|
|
354
|
+
"""Pad content to width, accounting for ANSI codes."""
|
|
355
|
+
visible_len = len(self._strip_ansi(content))
|
|
356
|
+
padding = width - visible_len
|
|
357
|
+
if padding <= 0:
|
|
358
|
+
return content
|
|
359
|
+
if align == "center":
|
|
360
|
+
left_pad = padding // 2
|
|
361
|
+
right_pad = padding - left_pad
|
|
362
|
+
return " " * left_pad + content + " " * right_pad
|
|
363
|
+
elif align == "right":
|
|
364
|
+
return " " * padding + content
|
|
365
|
+
return content + " " * padding
|
|
366
|
+
|
|
367
|
+
def _progress_bar(
|
|
368
|
+
self,
|
|
369
|
+
percent: float,
|
|
370
|
+
width: int = 20,
|
|
371
|
+
filled_color: str = Colors.GREEN,
|
|
372
|
+
empty_color: str = Colors.DIM,
|
|
373
|
+
show_percent: bool = True,
|
|
374
|
+
) -> str:
|
|
375
|
+
"""Create a colored progress bar."""
|
|
376
|
+
percent = max(0, min(100, percent))
|
|
377
|
+
filled = int((percent / 100) * width)
|
|
378
|
+
empty = width - filled
|
|
379
|
+
|
|
380
|
+
bar = f"{filled_color}{Box.BLOCK_FULL * filled}{Colors.RESET}"
|
|
381
|
+
bar += f"{empty_color}{Box.BLOCK_EMPTY * empty}{Colors.RESET}"
|
|
382
|
+
|
|
383
|
+
if show_percent:
|
|
384
|
+
bar += f" {percent:5.1f}%"
|
|
385
|
+
|
|
386
|
+
return bar
|
|
387
|
+
|
|
388
|
+
def _get_context_color(self, percent: float) -> str:
|
|
389
|
+
"""Get color based on context usage level."""
|
|
390
|
+
if percent >= 85:
|
|
391
|
+
return Colors.BRIGHT_RED
|
|
392
|
+
elif percent >= 75:
|
|
393
|
+
return Colors.RED
|
|
394
|
+
elif percent >= 65:
|
|
395
|
+
return Colors.YELLOW
|
|
396
|
+
elif percent >= 50:
|
|
397
|
+
return Colors.BRIGHT_YELLOW
|
|
398
|
+
return Colors.GREEN
|
|
399
|
+
|
|
400
|
+
def _get_cost_color(self, percent: float) -> str:
|
|
401
|
+
"""Get color based on cost usage level."""
|
|
402
|
+
if percent >= 90:
|
|
403
|
+
return Colors.BRIGHT_RED
|
|
404
|
+
elif percent >= 75:
|
|
405
|
+
return Colors.YELLOW
|
|
406
|
+
return Colors.GREEN
|
|
407
|
+
|
|
408
|
+
def _format_time(self, seconds: float) -> str:
|
|
409
|
+
"""Format seconds to MM:SS or HH:MM:SS."""
|
|
410
|
+
if seconds < 0:
|
|
411
|
+
return "0:00"
|
|
412
|
+
hours = int(seconds // 3600)
|
|
413
|
+
minutes = int((seconds % 3600) // 60)
|
|
414
|
+
secs = int(seconds % 60)
|
|
415
|
+
if hours > 0:
|
|
416
|
+
return f"{hours}:{minutes:02d}:{secs:02d}"
|
|
417
|
+
return f"{minutes}:{secs:02d}"
|
|
418
|
+
|
|
419
|
+
def _format_tokens(self, tokens: int) -> str:
|
|
420
|
+
"""Format token count with K/M suffix."""
|
|
421
|
+
if tokens >= 1_000_000:
|
|
422
|
+
return f"{tokens / 1_000_000:.1f}M"
|
|
423
|
+
elif tokens >= 1000:
|
|
424
|
+
return f"{tokens / 1000:.1f}K"
|
|
425
|
+
return str(tokens)
|
|
426
|
+
|
|
427
|
+
def _get_trend_indicator(self) -> str:
|
|
428
|
+
"""Calculate trend from recent token history."""
|
|
429
|
+
history = self.state.token_history
|
|
430
|
+
if len(history) < 2:
|
|
431
|
+
return f"{Colors.DIM}{Box.ARROW_FLAT}{Colors.RESET}"
|
|
432
|
+
|
|
433
|
+
# Compare last two entries
|
|
434
|
+
recent = history[-1].get("context", 0)
|
|
435
|
+
previous = history[-2].get("context", 0)
|
|
436
|
+
|
|
437
|
+
if recent > previous:
|
|
438
|
+
delta = recent - previous
|
|
439
|
+
if delta > 10000:
|
|
440
|
+
return f"{Colors.RED}{Box.ARROW_UP}{Box.ARROW_UP}{Colors.RESET}"
|
|
441
|
+
return f"{Colors.YELLOW}{Box.ARROW_UP}{Colors.RESET}"
|
|
442
|
+
elif recent < previous:
|
|
443
|
+
return f"{Colors.GREEN}{Box.ARROW_DOWN}{Colors.RESET}"
|
|
444
|
+
return f"{Colors.DIM}{Box.ARROW_FLAT}{Colors.RESET}"
|
|
445
|
+
|
|
446
|
+
def _render_header(self) -> list[str]:
|
|
447
|
+
"""Render dashboard header."""
|
|
448
|
+
lines = []
|
|
449
|
+
|
|
450
|
+
# Top border with title
|
|
451
|
+
title = " DEVFLOW LIVE DASHBOARD "
|
|
452
|
+
title_colored = f"{Colors.BOLD}{Colors.CYAN}{title}{Colors.RESET}"
|
|
453
|
+
|
|
454
|
+
left_border = Box.TOP_LEFT + Box.HORIZONTAL * 2
|
|
455
|
+
right_border = Box.HORIZONTAL * 2 + Box.TOP_RIGHT
|
|
456
|
+
middle_width = self.width - len(left_border) - len(right_border) - len(title)
|
|
457
|
+
left_fill = Box.HORIZONTAL * (middle_width // 2)
|
|
458
|
+
right_fill = Box.HORIZONTAL * (middle_width - middle_width // 2)
|
|
459
|
+
|
|
460
|
+
lines.append(
|
|
461
|
+
f"{Colors.CYAN}{left_border}{left_fill}{Colors.RESET}{title_colored}{Colors.CYAN}{right_fill}{right_border}{Colors.RESET}"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Status line
|
|
465
|
+
spinner = Box.SPINNER[self.spinner_index % len(Box.SPINNER)]
|
|
466
|
+
status_indicator = (
|
|
467
|
+
f"{Colors.GREEN}{spinner}{Colors.RESET}"
|
|
468
|
+
if self.state.is_active
|
|
469
|
+
else f"{Colors.DIM}{Box.DOT_EMPTY}{Colors.RESET}"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
story_display = f"Story: {Colors.BOLD}{self.story_key}{Colors.RESET}"
|
|
473
|
+
time_display = f"{Colors.DIM}{datetime.now().strftime('%H:%M:%S')}{Colors.RESET}"
|
|
474
|
+
|
|
475
|
+
status_content = f" {status_indicator} {story_display}{''.ljust(20)}{time_display} "
|
|
476
|
+
lines.append(
|
|
477
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(status_content, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Separator
|
|
481
|
+
lines.append(
|
|
482
|
+
f"{Colors.CYAN}{Box.T_LEFT}{Box.HORIZONTAL * (self.width - 2)}{Box.T_RIGHT}{Colors.RESET}"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
return lines
|
|
486
|
+
|
|
487
|
+
def _render_activity(self) -> list[str]:
|
|
488
|
+
"""Render current activity section."""
|
|
489
|
+
lines = []
|
|
490
|
+
|
|
491
|
+
# Section header
|
|
492
|
+
section_title = f" {Colors.BOLD}ACTIVITY{Colors.RESET} "
|
|
493
|
+
lines.append(
|
|
494
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(section_title, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Agent and phase
|
|
498
|
+
if self.state.current_agent:
|
|
499
|
+
agent_color = self.AGENT_COLORS.get(self.state.current_agent, Colors.WHITE)
|
|
500
|
+
agent_display = f"{agent_color}{Colors.BOLD}{self.state.current_agent}{Colors.RESET}"
|
|
501
|
+
|
|
502
|
+
phase_display = ""
|
|
503
|
+
if self.state.total_phases > 0:
|
|
504
|
+
phase_display = f" [{self.state.phases_completed + 1}/{self.state.total_phases}]"
|
|
505
|
+
|
|
506
|
+
if self.state.current_phase:
|
|
507
|
+
phase_name = self.state.current_phase
|
|
508
|
+
if len(phase_name) > 25:
|
|
509
|
+
phase_name = phase_name[:22] + "..."
|
|
510
|
+
phase_display += f" {Colors.DIM}{phase_name}{Colors.RESET}"
|
|
511
|
+
|
|
512
|
+
# Elapsed time
|
|
513
|
+
time_display = ""
|
|
514
|
+
if self.state.phase_start_time:
|
|
515
|
+
elapsed = (datetime.now() - self.state.phase_start_time).total_seconds()
|
|
516
|
+
time_display = f" {Colors.DIM}({self._format_time(elapsed)}){Colors.RESET}"
|
|
517
|
+
|
|
518
|
+
agent_line = f" Agent: {agent_display}{phase_display}{time_display}"
|
|
519
|
+
else:
|
|
520
|
+
agent_line = f" {Colors.DIM}No active agent{Colors.RESET}"
|
|
521
|
+
|
|
522
|
+
lines.append(
|
|
523
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(agent_line, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Task
|
|
527
|
+
if self.state.current_task:
|
|
528
|
+
task = self.state.current_task
|
|
529
|
+
max_task_len = self.width - 12
|
|
530
|
+
if len(task) > max_task_len:
|
|
531
|
+
task = task[: max_task_len - 3] + "..."
|
|
532
|
+
task_line = f" Task: {Colors.DIM}{task}{Colors.RESET}"
|
|
533
|
+
else:
|
|
534
|
+
task_line = f" {Colors.DIM}No active task{Colors.RESET}"
|
|
535
|
+
|
|
536
|
+
lines.append(
|
|
537
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(task_line, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Separator
|
|
541
|
+
lines.append(
|
|
542
|
+
f"{Colors.CYAN}{Box.T_LEFT}{Box.HORIZONTAL * (self.width - 2)}{Box.T_RIGHT}{Colors.RESET}"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
return lines
|
|
546
|
+
|
|
547
|
+
def _render_context(self) -> list[str]:
|
|
548
|
+
"""Render context usage section."""
|
|
549
|
+
lines = []
|
|
550
|
+
|
|
551
|
+
# Section header
|
|
552
|
+
section_title = f" {Colors.BOLD}CONTEXT{Colors.RESET} "
|
|
553
|
+
lines.append(
|
|
554
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(section_title, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Progress bar
|
|
558
|
+
ctx_color = self._get_context_color(self.state.context_percent)
|
|
559
|
+
bar = self._progress_bar(
|
|
560
|
+
self.state.context_percent,
|
|
561
|
+
width=30,
|
|
562
|
+
filled_color=ctx_color,
|
|
563
|
+
)
|
|
564
|
+
trend = self._get_trend_indicator()
|
|
565
|
+
|
|
566
|
+
ctx_line = f" Usage: {bar} {trend}"
|
|
567
|
+
lines.append(
|
|
568
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(ctx_line, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# Details line
|
|
572
|
+
tokens_display = f"{self._format_tokens(self.state.context_tokens)}/{self._format_tokens(self.state.context_window)}"
|
|
573
|
+
remaining_display = f"~{self.state.exchanges_remaining} exchanges left"
|
|
574
|
+
|
|
575
|
+
if self.state.context_percent >= 75:
|
|
576
|
+
remaining_display = f"{Colors.YELLOW}{remaining_display}{Colors.RESET}"
|
|
577
|
+
elif self.state.context_percent >= 85:
|
|
578
|
+
remaining_display = f"{Colors.RED}{remaining_display}{Colors.RESET}"
|
|
579
|
+
|
|
580
|
+
detail_line = f" Tokens: {Colors.DIM}{tokens_display}{Colors.RESET} {remaining_display}"
|
|
581
|
+
lines.append(
|
|
582
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(detail_line, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# Warning if critical
|
|
586
|
+
if self.state.context_percent >= 85:
|
|
587
|
+
warning = f" {Colors.BG_RED}{Colors.WHITE} CHECKPOINT RECOMMENDED {Colors.RESET}"
|
|
588
|
+
lines.append(
|
|
589
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(warning, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
590
|
+
)
|
|
591
|
+
elif self.state.context_percent >= 75:
|
|
592
|
+
warning = (
|
|
593
|
+
f" {Colors.YELLOW}[!] Context filling up - plan to checkpoint soon{Colors.RESET}"
|
|
594
|
+
)
|
|
595
|
+
lines.append(
|
|
596
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(warning, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
# Separator
|
|
600
|
+
lines.append(
|
|
601
|
+
f"{Colors.CYAN}{Box.T_LEFT}{Box.HORIZONTAL * (self.width - 2)}{Box.T_RIGHT}{Colors.RESET}"
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
return lines
|
|
605
|
+
|
|
606
|
+
def _render_cost(self) -> list[str]:
|
|
607
|
+
"""Render cost tracking section."""
|
|
608
|
+
lines = []
|
|
609
|
+
|
|
610
|
+
# Section header
|
|
611
|
+
section_title = f" {Colors.BOLD}COST{Colors.RESET} "
|
|
612
|
+
lines.append(
|
|
613
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(section_title, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# Progress bar
|
|
617
|
+
cost_color = self._get_cost_color(self.state.cost_percent)
|
|
618
|
+
bar = self._progress_bar(
|
|
619
|
+
self.state.cost_percent,
|
|
620
|
+
width=30,
|
|
621
|
+
filled_color=cost_color,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
cost_line = f" Budget: {bar}"
|
|
625
|
+
lines.append(
|
|
626
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(cost_line, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# Cost details
|
|
630
|
+
cost_display = f"${self.state.cost_usd:.2f} / ${self.state.budget_usd:.2f}"
|
|
631
|
+
tokens_display = f"In: {self._format_tokens(self.state.input_tokens)} Out: {self._format_tokens(self.state.output_tokens)}"
|
|
632
|
+
|
|
633
|
+
detail_line = f" Spent: {Colors.BOLD}{cost_display}{Colors.RESET} {Colors.DIM}{tokens_display}{Colors.RESET}"
|
|
634
|
+
lines.append(
|
|
635
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(detail_line, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
# Separator
|
|
639
|
+
lines.append(
|
|
640
|
+
f"{Colors.CYAN}{Box.T_LEFT}{Box.HORIZONTAL * (self.width - 2)}{Box.T_RIGHT}{Colors.RESET}"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
return lines
|
|
644
|
+
|
|
645
|
+
def _render_history(self) -> list[str]:
|
|
646
|
+
"""Render recent token history."""
|
|
647
|
+
lines = []
|
|
648
|
+
|
|
649
|
+
# Section header
|
|
650
|
+
section_title = f" {Colors.BOLD}RECENT{Colors.RESET} "
|
|
651
|
+
lines.append(
|
|
652
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(section_title, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
history = self.state.token_history[-5:] # Last 5 entries
|
|
656
|
+
|
|
657
|
+
if not history:
|
|
658
|
+
empty_line = f" {Colors.DIM}No recent activity{Colors.RESET}"
|
|
659
|
+
lines.append(
|
|
660
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(empty_line, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
661
|
+
)
|
|
662
|
+
else:
|
|
663
|
+
for entry in reversed(history):
|
|
664
|
+
try:
|
|
665
|
+
ts = datetime.fromisoformat(entry.get("timestamp", ""))
|
|
666
|
+
elapsed = (datetime.now() - ts).total_seconds()
|
|
667
|
+
|
|
668
|
+
if elapsed < 60:
|
|
669
|
+
time_str = f"{int(elapsed)}s ago"
|
|
670
|
+
elif elapsed < 3600:
|
|
671
|
+
time_str = f"{int(elapsed // 60)}m ago"
|
|
672
|
+
else:
|
|
673
|
+
time_str = f"{int(elapsed // 3600)}h ago"
|
|
674
|
+
|
|
675
|
+
tokens = entry.get("input", 0) + entry.get("output", 0)
|
|
676
|
+
entry_line = f" {Colors.DIM}{Box.ARROW_RIGHT}{Colors.RESET} +{self._format_tokens(tokens)} tokens {Colors.DIM}({time_str}){Colors.RESET}"
|
|
677
|
+
lines.append(
|
|
678
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(entry_line, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
679
|
+
)
|
|
680
|
+
except (ValueError, TypeError, KeyError):
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
return lines
|
|
684
|
+
|
|
685
|
+
def _render_footer(self) -> list[str]:
|
|
686
|
+
"""Render dashboard footer."""
|
|
687
|
+
lines = []
|
|
688
|
+
|
|
689
|
+
# Session stats
|
|
690
|
+
session_elapsed = (datetime.now() - self.state.session_start).total_seconds()
|
|
691
|
+
session_time = self._format_time(session_elapsed)
|
|
692
|
+
|
|
693
|
+
if self.state.last_file_update:
|
|
694
|
+
last_update = self.state.last_file_update.strftime("%H:%M:%S")
|
|
695
|
+
else:
|
|
696
|
+
last_update = "N/A"
|
|
697
|
+
|
|
698
|
+
footer_content = f" Session: {session_time} {Colors.DIM}|{Colors.RESET} Last update: {last_update} {Colors.DIM}|{Colors.RESET} {Colors.DIM}Ctrl+C to exit{Colors.RESET}"
|
|
699
|
+
lines.append(
|
|
700
|
+
f"{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}{self._pad_content(footer_content, self.width - 2)}{Colors.CYAN}{Box.VERTICAL}{Colors.RESET}"
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Bottom border
|
|
704
|
+
lines.append(
|
|
705
|
+
f"{Colors.CYAN}{Box.BOTTOM_LEFT}{Box.HORIZONTAL * (self.width - 2)}{Box.BOTTOM_RIGHT}{Colors.RESET}"
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
return lines
|
|
709
|
+
|
|
710
|
+
def _render_compact(self) -> str:
|
|
711
|
+
"""Render compact single-line status."""
|
|
712
|
+
agent = self.state.current_agent or "Idle"
|
|
713
|
+
ctx = f"{self.state.context_percent:.0f}%"
|
|
714
|
+
cost = f"${self.state.cost_usd:.2f}"
|
|
715
|
+
|
|
716
|
+
ctx_color = self._get_context_color(self.state.context_percent)
|
|
717
|
+
cost_color = self._get_cost_color(self.state.cost_percent)
|
|
718
|
+
agent_color = self.AGENT_COLORS.get(self.state.current_agent, Colors.DIM)
|
|
719
|
+
|
|
720
|
+
spinner = (
|
|
721
|
+
Box.SPINNER[self.spinner_index % len(Box.SPINNER)]
|
|
722
|
+
if self.state.is_active
|
|
723
|
+
else Box.DOT_EMPTY
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
return f"{spinner} {agent_color}{agent}{Colors.RESET} | Ctx: {ctx_color}{ctx}{Colors.RESET} | Cost: {cost_color}{cost}{Colors.RESET} | {datetime.now().strftime('%H:%M:%S')}"
|
|
727
|
+
|
|
728
|
+
def render(self) -> str:
|
|
729
|
+
"""Render the full dashboard."""
|
|
730
|
+
if self.compact:
|
|
731
|
+
return self._render_compact()
|
|
732
|
+
|
|
733
|
+
lines = []
|
|
734
|
+
lines.extend(self._render_header())
|
|
735
|
+
lines.extend(self._render_activity())
|
|
736
|
+
lines.extend(self._render_context())
|
|
737
|
+
lines.extend(self._render_cost())
|
|
738
|
+
lines.extend(self._render_history())
|
|
739
|
+
lines.extend(self._render_footer())
|
|
740
|
+
|
|
741
|
+
return "\n".join(lines)
|
|
742
|
+
|
|
743
|
+
def run(self):
|
|
744
|
+
"""Run the dashboard update loop."""
|
|
745
|
+
self.running = True
|
|
746
|
+
|
|
747
|
+
# Setup signal handler for clean exit
|
|
748
|
+
def signal_handler(sig, frame):
|
|
749
|
+
self.running = False
|
|
750
|
+
|
|
751
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
752
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
753
|
+
|
|
754
|
+
try:
|
|
755
|
+
self._hide_cursor()
|
|
756
|
+
self._clear_screen()
|
|
757
|
+
|
|
758
|
+
while self.running:
|
|
759
|
+
# Refresh state from files
|
|
760
|
+
self.state.refresh()
|
|
761
|
+
|
|
762
|
+
# Render dashboard
|
|
763
|
+
if self.compact:
|
|
764
|
+
print(f"\r{self.render()}", end="", flush=True)
|
|
765
|
+
else:
|
|
766
|
+
self._move_cursor_home()
|
|
767
|
+
print(self.render(), flush=True)
|
|
768
|
+
|
|
769
|
+
# Update spinner
|
|
770
|
+
self.spinner_index += 1
|
|
771
|
+
self.frame_count += 1
|
|
772
|
+
|
|
773
|
+
# Wait for next refresh
|
|
774
|
+
time.sleep(self.refresh_interval)
|
|
775
|
+
|
|
776
|
+
finally:
|
|
777
|
+
self._show_cursor()
|
|
778
|
+
if not self.compact:
|
|
779
|
+
print() # Newline after exit
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def main():
|
|
783
|
+
"""Main entry point."""
|
|
784
|
+
parser = argparse.ArgumentParser(
|
|
785
|
+
description="Live dashboard for Devflow status monitoring",
|
|
786
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
787
|
+
epilog="""
|
|
788
|
+
Examples:
|
|
789
|
+
python live_dashboard.py 3-5
|
|
790
|
+
python live_dashboard.py 3-5 --refresh 0.25
|
|
791
|
+
python live_dashboard.py --compact
|
|
792
|
+
python live_dashboard.py --width 80
|
|
793
|
+
""",
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
parser.add_argument(
|
|
797
|
+
"story_key", nargs="?", default="default", help="Story key to monitor (default: 'default')"
|
|
798
|
+
)
|
|
799
|
+
parser.add_argument(
|
|
800
|
+
"--refresh",
|
|
801
|
+
"-r",
|
|
802
|
+
type=float,
|
|
803
|
+
default=0.5,
|
|
804
|
+
help="Refresh interval in seconds (default: 0.5)",
|
|
805
|
+
)
|
|
806
|
+
parser.add_argument("--compact", "-c", action="store_true", help="Use compact single-line mode")
|
|
807
|
+
parser.add_argument("--no-color", action="store_true", help="Disable colors")
|
|
808
|
+
parser.add_argument("--width", "-w", type=int, default=70, help="Dashboard width (default: 70)")
|
|
809
|
+
|
|
810
|
+
args = parser.parse_args()
|
|
811
|
+
|
|
812
|
+
if args.no_color:
|
|
813
|
+
Colors.disable()
|
|
814
|
+
|
|
815
|
+
dashboard = LiveDashboard(
|
|
816
|
+
story_key=args.story_key,
|
|
817
|
+
width=args.width,
|
|
818
|
+
refresh_interval=args.refresh,
|
|
819
|
+
compact=args.compact,
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
try:
|
|
823
|
+
dashboard.run()
|
|
824
|
+
except KeyboardInterrupt:
|
|
825
|
+
pass
|
|
826
|
+
|
|
827
|
+
print(f"\n{Colors.DIM}Dashboard stopped.{Colors.RESET}")
|
|
828
|
+
return 0
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
if __name__ == "__main__":
|
|
832
|
+
sys.exit(main())
|