@pjmendonca/devflow 1.18.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.
Files changed (49) hide show
  1. package/.claude/commands/brainstorm.md +28 -0
  2. package/.claude/commands/init.md +102 -6
  3. package/.claude/skills/brainstorm/SKILL.md +531 -0
  4. package/.claude/skills/dashboard/SKILL.md +118 -0
  5. package/CHANGELOG.md +62 -0
  6. package/README.md +2 -2
  7. package/bin/devflow-dashboard.js +10 -0
  8. package/bin/devflow-swarm.js +11 -0
  9. package/bin/devflow.js +2 -0
  10. package/package.json +3 -1
  11. package/tooling/.automation/memory/knowledge/kg_integration-test.json +116 -1
  12. package/tooling/.automation/memory/knowledge/kg_test-story.json +392 -2
  13. package/tooling/.automation/memory/shared/shared_integration-test.json +37 -1
  14. package/tooling/.automation/memory/shared/shared_test-story.json +109 -1
  15. package/tooling/.automation/memory/shared/shared_test.json +235 -1
  16. package/tooling/.automation/memory/shared/shared_validation-check.json +40 -1
  17. package/tooling/.automation/validation/history/2026-01-03_val_1287a74c.json +41 -0
  18. package/tooling/.automation/validation/history/2026-01-03_val_3b24071f.json +32 -0
  19. package/tooling/.automation/validation/history/2026-01-03_val_44d77573.json +32 -0
  20. package/tooling/.automation/validation/history/2026-01-03_val_5b31dc51.json +32 -0
  21. package/tooling/.automation/validation/history/2026-01-03_val_74267244.json +32 -0
  22. package/tooling/.automation/validation/history/2026-01-03_val_8b2d95c7.json +59 -0
  23. package/tooling/.automation/validation/history/2026-01-03_val_d875b297.json +41 -0
  24. package/tooling/.automation/validation/history/2026-01-16_val_0b81ec2f.json +41 -0
  25. package/tooling/.automation/validation/history/2026-01-16_val_26c18e64.json +32 -0
  26. package/tooling/.automation/validation/history/2026-01-16_val_32af0152.json +32 -0
  27. package/tooling/.automation/validation/history/2026-01-16_val_353d1569.json +32 -0
  28. package/tooling/.automation/validation/history/2026-01-16_val_39e3c143.json +59 -0
  29. package/tooling/.automation/validation/history/2026-01-16_val_77fb42e4.json +32 -0
  30. package/tooling/.automation/validation/history/2026-01-16_val_a0752656.json +41 -0
  31. package/tooling/.automation/validation/history/2026-01-16_val_a29213b0.json +41 -0
  32. package/tooling/.automation/validation/history/2026-01-16_val_a9375d4c.json +32 -0
  33. package/tooling/.automation/validation/history/2026-01-16_val_c147bbdf.json +32 -0
  34. package/tooling/.automation/validation/history/2026-01-16_val_d06ccf8d.json +32 -0
  35. package/tooling/.automation/validation/history/2026-01-16_val_d6a80295.json +59 -0
  36. package/tooling/.automation/validation/history/2026-01-16_val_dce5005d.json +41 -0
  37. package/tooling/.automation/validation/history/2026-01-16_val_e53b3a63.json +32 -0
  38. package/tooling/docs/stories/.gitkeep +0 -0
  39. package/tooling/docs/templates/brainstorm-guide.md +314 -0
  40. package/tooling/docs/templates/story.md +66 -0
  41. package/tooling/scripts/lib/__init__.py +1 -1
  42. package/tooling/scripts/lib/cost_display.py +7 -1
  43. package/tooling/scripts/lib/pair_programming.py +6 -4
  44. package/tooling/scripts/lib/swarm_orchestrator.py +13 -11
  45. package/tooling/scripts/live_dashboard.py +832 -0
  46. package/tooling/scripts/new-doc.py +1 -1
  47. package/tooling/scripts/run-collab.py +2 -2
  48. package/tooling/scripts/run-story.py +21 -9
  49. 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())