galangal-orchestrate 0.13.0__py3-none-any.whl

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 (79) hide show
  1. galangal/__init__.py +36 -0
  2. galangal/__main__.py +6 -0
  3. galangal/ai/__init__.py +167 -0
  4. galangal/ai/base.py +159 -0
  5. galangal/ai/claude.py +352 -0
  6. galangal/ai/codex.py +370 -0
  7. galangal/ai/gemini.py +43 -0
  8. galangal/ai/subprocess.py +254 -0
  9. galangal/cli.py +371 -0
  10. galangal/commands/__init__.py +27 -0
  11. galangal/commands/complete.py +367 -0
  12. galangal/commands/github.py +355 -0
  13. galangal/commands/init.py +177 -0
  14. galangal/commands/init_wizard.py +762 -0
  15. galangal/commands/list.py +20 -0
  16. galangal/commands/pause.py +34 -0
  17. galangal/commands/prompts.py +89 -0
  18. galangal/commands/reset.py +41 -0
  19. galangal/commands/resume.py +30 -0
  20. galangal/commands/skip.py +62 -0
  21. galangal/commands/start.py +530 -0
  22. galangal/commands/status.py +44 -0
  23. galangal/commands/switch.py +28 -0
  24. galangal/config/__init__.py +15 -0
  25. galangal/config/defaults.py +183 -0
  26. galangal/config/loader.py +163 -0
  27. galangal/config/schema.py +330 -0
  28. galangal/core/__init__.py +33 -0
  29. galangal/core/artifacts.py +136 -0
  30. galangal/core/state.py +1097 -0
  31. galangal/core/tasks.py +454 -0
  32. galangal/core/utils.py +116 -0
  33. galangal/core/workflow/__init__.py +68 -0
  34. galangal/core/workflow/core.py +789 -0
  35. galangal/core/workflow/engine.py +781 -0
  36. galangal/core/workflow/pause.py +35 -0
  37. galangal/core/workflow/tui_runner.py +1322 -0
  38. galangal/exceptions.py +36 -0
  39. galangal/github/__init__.py +31 -0
  40. galangal/github/client.py +427 -0
  41. galangal/github/images.py +324 -0
  42. galangal/github/issues.py +298 -0
  43. galangal/logging.py +364 -0
  44. galangal/prompts/__init__.py +5 -0
  45. galangal/prompts/builder.py +527 -0
  46. galangal/prompts/defaults/benchmark.md +34 -0
  47. galangal/prompts/defaults/contract.md +35 -0
  48. galangal/prompts/defaults/design.md +54 -0
  49. galangal/prompts/defaults/dev.md +89 -0
  50. galangal/prompts/defaults/docs.md +104 -0
  51. galangal/prompts/defaults/migration.md +59 -0
  52. galangal/prompts/defaults/pm.md +110 -0
  53. galangal/prompts/defaults/pm_questions.md +53 -0
  54. galangal/prompts/defaults/preflight.md +32 -0
  55. galangal/prompts/defaults/qa.md +65 -0
  56. galangal/prompts/defaults/review.md +90 -0
  57. galangal/prompts/defaults/review_codex.md +99 -0
  58. galangal/prompts/defaults/security.md +84 -0
  59. galangal/prompts/defaults/test.md +91 -0
  60. galangal/results.py +176 -0
  61. galangal/ui/__init__.py +5 -0
  62. galangal/ui/console.py +126 -0
  63. galangal/ui/tui/__init__.py +56 -0
  64. galangal/ui/tui/adapters.py +168 -0
  65. galangal/ui/tui/app.py +902 -0
  66. galangal/ui/tui/entry.py +24 -0
  67. galangal/ui/tui/mixins.py +196 -0
  68. galangal/ui/tui/modals.py +339 -0
  69. galangal/ui/tui/styles/app.tcss +86 -0
  70. galangal/ui/tui/styles/modals.tcss +197 -0
  71. galangal/ui/tui/types.py +107 -0
  72. galangal/ui/tui/widgets.py +263 -0
  73. galangal/validation/__init__.py +5 -0
  74. galangal/validation/runner.py +1072 -0
  75. galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
  76. galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
  77. galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
  78. galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
  79. galangal_orchestrate-0.13.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,197 @@
1
+ /*
2
+ * Modal Styles
3
+ * Shared styles for all modal screens
4
+ */
5
+
6
+ /* Base modal centering */
7
+ PromptModal,
8
+ TextInputModal,
9
+ QuestionAnswerModal,
10
+ UserQuestionsModal,
11
+ MultilineInputModal {
12
+ align: center middle;
13
+ layout: vertical;
14
+ }
15
+
16
+ /* ============================================
17
+ * Prompt Modal (multi-choice selections)
18
+ * ============================================ */
19
+
20
+ #prompt-dialog {
21
+ width: 90%;
22
+ max-width: 120;
23
+ min-width: 50;
24
+ max-height: 80%;
25
+ background: #3c3836;
26
+ border: round #504945;
27
+ padding: 1 2;
28
+ layout: vertical;
29
+ overflow-y: auto;
30
+ }
31
+
32
+ #prompt-message {
33
+ color: #ebdbb2;
34
+ text-style: bold;
35
+ margin-bottom: 1;
36
+ text-wrap: wrap;
37
+ }
38
+
39
+ #prompt-options {
40
+ color: #ebdbb2;
41
+ }
42
+
43
+ #prompt-hint {
44
+ color: #7c6f64;
45
+ margin-top: 1;
46
+ }
47
+
48
+ /* ============================================
49
+ * Text Input Modal (single-line)
50
+ * ============================================ */
51
+
52
+ #text-input-dialog {
53
+ width: 70%;
54
+ max-width: 80;
55
+ min-width: 40;
56
+ background: #3c3836;
57
+ border: round #504945;
58
+ padding: 1 2;
59
+ layout: vertical;
60
+ }
61
+
62
+ #text-input-label {
63
+ color: #ebdbb2;
64
+ text-style: bold;
65
+ margin-bottom: 1;
66
+ text-wrap: wrap;
67
+ }
68
+
69
+ #text-input-field {
70
+ width: 100%;
71
+ }
72
+
73
+ #text-input-hint {
74
+ color: #7c6f64;
75
+ margin-top: 1;
76
+ }
77
+
78
+ /* ============================================
79
+ * Question Answer Modal (discovery Q&A)
80
+ * ============================================ */
81
+
82
+ #qa-dialog {
83
+ width: 90%;
84
+ max-width: 100;
85
+ min-width: 60;
86
+ max-height: 85%;
87
+ background: #3c3836;
88
+ border: round #504945;
89
+ padding: 1 2;
90
+ layout: vertical;
91
+ overflow-y: auto;
92
+ }
93
+
94
+ #qa-title {
95
+ color: #fe8019;
96
+ text-style: bold;
97
+ margin-bottom: 1;
98
+ }
99
+
100
+ #qa-questions {
101
+ color: #a89984;
102
+ margin-bottom: 1;
103
+ padding: 0 1;
104
+ }
105
+
106
+ #qa-current-question {
107
+ color: #ebdbb2;
108
+ text-style: bold;
109
+ margin-bottom: 1;
110
+ padding: 0 1;
111
+ }
112
+
113
+ #qa-input-field {
114
+ width: 100%;
115
+ margin-bottom: 1;
116
+ }
117
+
118
+ #qa-progress {
119
+ color: #7c6f64;
120
+ }
121
+
122
+ #qa-hint {
123
+ color: #7c6f64;
124
+ margin-top: 1;
125
+ }
126
+
127
+ /* ============================================
128
+ * User Questions Modal (enter own questions)
129
+ * ============================================ */
130
+
131
+ #user-questions-dialog {
132
+ width: 90%;
133
+ max-width: 100;
134
+ min-width: 50;
135
+ height: auto;
136
+ max-height: 80%;
137
+ background: #3c3836;
138
+ border: round #504945;
139
+ padding: 1 2;
140
+ layout: vertical;
141
+ }
142
+
143
+ #user-questions-label {
144
+ color: #ebdbb2;
145
+ text-style: bold;
146
+ margin-bottom: 1;
147
+ text-wrap: wrap;
148
+ }
149
+
150
+ #user-questions-field {
151
+ width: 100%;
152
+ height: 10;
153
+ min-height: 6;
154
+ background: #282828;
155
+ border: solid #504945;
156
+ }
157
+
158
+ #user-questions-hint {
159
+ color: #7c6f64;
160
+ margin-top: 1;
161
+ }
162
+
163
+ /* ============================================
164
+ * Multiline Input Modal (task descriptions)
165
+ * ============================================ */
166
+
167
+ #multiline-input-dialog {
168
+ width: 90%;
169
+ max-width: 100;
170
+ min-width: 50;
171
+ height: auto;
172
+ max-height: 80%;
173
+ background: #3c3836;
174
+ border: round #504945;
175
+ padding: 1 2;
176
+ layout: vertical;
177
+ }
178
+
179
+ #multiline-input-label {
180
+ color: #ebdbb2;
181
+ text-style: bold;
182
+ margin-bottom: 1;
183
+ text-wrap: wrap;
184
+ }
185
+
186
+ #multiline-input-field {
187
+ width: 100%;
188
+ height: 12;
189
+ min-height: 6;
190
+ background: #282828;
191
+ border: solid #504945;
192
+ }
193
+
194
+ #multiline-input-hint {
195
+ color: #7c6f64;
196
+ margin-top: 1;
197
+ }
@@ -0,0 +1,107 @@
1
+ """
2
+ Activity log types for structured logging.
3
+
4
+ Provides structured activity entries with level, category, and optional details
5
+ for filtering and export capabilities.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from enum import Enum
11
+ from pathlib import Path
12
+
13
+
14
+ class ActivityLevel(str, Enum):
15
+ """Log entry severity level."""
16
+
17
+ INFO = "info"
18
+ SUCCESS = "success"
19
+ WARNING = "warning"
20
+ ERROR = "error"
21
+
22
+
23
+ class ActivityCategory(str, Enum):
24
+ """Log entry category for filtering."""
25
+
26
+ STAGE = "stage" # Stage transitions
27
+ VALIDATION = "validation" # Validation results
28
+ CLAUDE = "claude" # AI backend activity
29
+ FILE = "file" # File operations
30
+ SYSTEM = "system" # System messages
31
+
32
+
33
+ @dataclass
34
+ class ActivityEntry:
35
+ """
36
+ Structured activity log entry.
37
+
38
+ Captures rich metadata about each activity for filtering,
39
+ display, and export purposes.
40
+
41
+ Attributes:
42
+ timestamp: When the activity occurred (UTC).
43
+ level: Severity level (info, success, warning, error).
44
+ category: Category for filtering (stage, validation, claude, file, system).
45
+ message: Human-readable message.
46
+ icon: Deprecated, kept for backwards compatibility.
47
+ details: Optional additional details (e.g., stack trace, full output).
48
+ """
49
+
50
+ message: str
51
+ level: ActivityLevel = ActivityLevel.INFO
52
+ category: ActivityCategory = ActivityCategory.SYSTEM
53
+ icon: str = "•"
54
+ details: str | None = None
55
+ timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
56
+
57
+ def format_display(self, show_timestamp: bool = True) -> str:
58
+ """
59
+ Format entry for display in TUI.
60
+
61
+ Args:
62
+ show_timestamp: Whether to include timestamp prefix.
63
+
64
+ Returns:
65
+ Formatted string with Rich markup.
66
+ """
67
+ colors = {
68
+ ActivityLevel.INFO: "#ebdbb2",
69
+ ActivityLevel.SUCCESS: "#b8bb26",
70
+ ActivityLevel.WARNING: "#fabd2f",
71
+ ActivityLevel.ERROR: "#fb4934",
72
+ }
73
+ color = colors.get(self.level, "#ebdbb2")
74
+
75
+ if show_timestamp:
76
+ time_str = self.timestamp.strftime("%H:%M:%S")
77
+ return f"[#928374]{time_str}[/] [{color}]{self.message}[/]"
78
+ return f"[{color}]{self.message}[/]"
79
+
80
+ def format_export(self) -> str:
81
+ """
82
+ Format entry for file export (plain text).
83
+
84
+ Returns:
85
+ Plain text line suitable for log file.
86
+ """
87
+ time_str = self.timestamp.isoformat()
88
+ line = f"{time_str} [{self.level.value}] [{self.category.value}] {self.message}"
89
+ if self.details:
90
+ line += f"\n {self.details}"
91
+ return line
92
+
93
+
94
+ def export_activity_log(entries: list[ActivityEntry], path: Path) -> None:
95
+ """
96
+ Export activity log entries to a file.
97
+
98
+ Args:
99
+ entries: List of activity entries to export.
100
+ path: File path to write to.
101
+ """
102
+ with open(path, "w") as f:
103
+ f.write("# Activity Log Export\n")
104
+ f.write(f"# Generated: {datetime.now(timezone.utc).isoformat()}\n")
105
+ f.write(f"# Entries: {len(entries)}\n\n")
106
+ for entry in entries:
107
+ f.write(entry.format_export() + "\n")
@@ -0,0 +1,263 @@
1
+ """
2
+ Custom Textual widgets for TUI display.
3
+ """
4
+
5
+ from rich.panel import Panel
6
+ from rich.text import Text
7
+ from textual.reactive import reactive
8
+ from textual.widgets import Static
9
+
10
+ from galangal import __version__
11
+ from galangal.core.state import STAGE_ORDER
12
+
13
+
14
+ class HeaderWidget(Static):
15
+ """Fixed header showing task info."""
16
+
17
+ task_name: reactive[str] = reactive("")
18
+ stage: reactive[str] = reactive("")
19
+ attempt: reactive[int] = reactive(1)
20
+ max_retries: reactive[int] = reactive(5)
21
+ elapsed: reactive[str] = reactive("0:00")
22
+ turns: reactive[int] = reactive(0)
23
+ status: reactive[str] = reactive("starting")
24
+
25
+ def render(self) -> Text:
26
+ text = Text()
27
+
28
+ # Row 1: Task, Stage, Attempt
29
+ text.append("Task: ", style="#928374")
30
+ text.append(self.task_name[:30], style="bold #83a598")
31
+ text.append(" Stage: ", style="#928374")
32
+ text.append(f"{self.stage}", style="bold #fabd2f")
33
+ text.append(f" ({self.attempt}/{self.max_retries})", style="#928374")
34
+ text.append(" Elapsed: ", style="#928374")
35
+ text.append(self.elapsed, style="bold #ebdbb2")
36
+ text.append(" Turns: ", style="#928374")
37
+ text.append(str(self.turns), style="bold #b8bb26")
38
+ text.append(f" v{__version__}", style="#665c54")
39
+
40
+ return text
41
+
42
+
43
+ def _format_duration(seconds: int) -> str:
44
+ """Format duration in seconds to human-readable string."""
45
+ if seconds >= 3600:
46
+ hours, remainder = divmod(seconds, 3600)
47
+ mins, secs = divmod(remainder, 60)
48
+ return f"{hours}:{mins:02d}:{secs:02d}"
49
+ else:
50
+ mins, secs = divmod(seconds, 60)
51
+ return f"{mins}:{secs:02d}"
52
+
53
+
54
+ class StageProgressWidget(Static):
55
+ """Centered stage progress bar with full names and durations."""
56
+
57
+ current_stage: reactive[str] = reactive("PM")
58
+ skipped_stages: reactive[frozenset] = reactive(frozenset())
59
+ hidden_stages: reactive[frozenset] = reactive(frozenset())
60
+ stage_durations: reactive[dict] = reactive({}, always_update=True)
61
+
62
+ # Full stage display names
63
+ STAGE_DISPLAY = {
64
+ "PM": "PM",
65
+ "DESIGN": "DESIGN",
66
+ "PREFLIGHT": "PREFLIGHT",
67
+ "DEV": "DEV",
68
+ "MIGRATION": "MIGRATION",
69
+ "TEST": "TEST",
70
+ "CONTRACT": "CONTRACT",
71
+ "QA": "QA",
72
+ "BENCHMARK": "BENCHMARK",
73
+ "SECURITY": "SECURITY",
74
+ "REVIEW": "REVIEW",
75
+ "DOCS": "DOCS",
76
+ "COMPLETE": "COMPLETE",
77
+ }
78
+
79
+ STAGE_COMPACT = {
80
+ "PM": "PM",
81
+ "DESIGN": "DSGN",
82
+ "PREFLIGHT": "PREF",
83
+ "DEV": "DEV",
84
+ "MIGRATION": "MIGR",
85
+ "TEST": "TEST",
86
+ "CONTRACT": "CNTR",
87
+ "QA": "QA",
88
+ "BENCHMARK": "BENCH",
89
+ "SECURITY": "SEC",
90
+ "REVIEW": "RVW",
91
+ "DOCS": "DOCS",
92
+ "COMPLETE": "DONE",
93
+ }
94
+
95
+ def render(self) -> Text:
96
+ text = Text(justify="center")
97
+
98
+ # Filter out hidden stages (task type + config skips)
99
+ visible_stages = [s for s in STAGE_ORDER if s.value not in self.hidden_stages]
100
+
101
+ try:
102
+ current_idx = next(
103
+ i for i, s in enumerate(visible_stages) if s.value == self.current_stage
104
+ )
105
+ except StopIteration:
106
+ current_idx = 0
107
+
108
+ width = self.size.width or 0
109
+ use_window = width and width < 70
110
+ use_compact = width and width < 110
111
+ display_names = self.STAGE_COMPACT if use_compact else self.STAGE_DISPLAY
112
+
113
+ stages = visible_stages
114
+ if use_window:
115
+ start = max(current_idx - 2, 0)
116
+ end = min(current_idx + 3, len(stages))
117
+ items: list[int | None] = []
118
+ if start > 0:
119
+ items.append(None)
120
+ items.extend(range(start, end))
121
+ if end < len(stages):
122
+ items.append(None)
123
+ else:
124
+ items = list(range(len(stages)))
125
+
126
+ for idx, stage_idx in enumerate(items):
127
+ if idx > 0:
128
+ text.append(" ━ ", style="#504945")
129
+ if stage_idx is None:
130
+ text.append("...", style="#504945")
131
+ continue
132
+
133
+ stage = stages[stage_idx]
134
+ name = display_names.get(stage.value, stage.value)
135
+
136
+ if stage.value in self.skipped_stages:
137
+ text.append(f"⊘ {name}", style="#504945 strike")
138
+ elif stage_idx < current_idx:
139
+ # Completed stage - show with duration if available
140
+ duration = self.stage_durations.get(stage.value)
141
+ if duration is not None:
142
+ duration_str = _format_duration(duration)
143
+ text.append(f"● {name} ", style="#b8bb26")
144
+ text.append(f"({duration_str})", style="#928374")
145
+ else:
146
+ text.append(f"● {name}", style="#b8bb26")
147
+ elif stage_idx == current_idx:
148
+ text.append(f"◉ {name}", style="bold #fabd2f")
149
+ else:
150
+ text.append(f"○ {name}", style="#504945")
151
+
152
+ return text
153
+
154
+
155
+ class CurrentActionWidget(Static):
156
+ """Shows the current action with animated spinner."""
157
+
158
+ action: reactive[str] = reactive("")
159
+ detail: reactive[str] = reactive("")
160
+ spinner_frame: reactive[int] = reactive(0)
161
+
162
+ SPINNERS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
163
+
164
+ def render(self) -> Text:
165
+ text = Text()
166
+ if self.action:
167
+ spinner = self.SPINNERS[self.spinner_frame % len(self.SPINNERS)]
168
+ text.append(f"{spinner} ", style="#83a598")
169
+ text.append(self.action, style="bold #ebdbb2")
170
+ if self.detail:
171
+ detail = self.detail
172
+ width = self.size.width or 0
173
+ if width:
174
+ reserved = len(self.action) + 4
175
+ max_detail = max(width - reserved, 0)
176
+ if max_detail and len(detail) > max_detail:
177
+ if max_detail > 3:
178
+ detail = detail[: max_detail - 3] + "..."
179
+ else:
180
+ detail = ""
181
+ if not detail:
182
+ return text
183
+ text.append(f": {detail}", style="#928374")
184
+ else:
185
+ text.append("○ Idle", style="#504945")
186
+ return text
187
+
188
+
189
+ class FilesPanelWidget(Static):
190
+ """Panel showing files that have been read/written."""
191
+
192
+ def __init__(self, **kwargs):
193
+ super().__init__(**kwargs)
194
+ self._files: list[tuple[str, str]] = []
195
+
196
+ def add_file(self, action: str, path: str) -> None:
197
+ """Add a file operation."""
198
+ entry = (action, path)
199
+ if entry not in self._files:
200
+ self._files.append(entry)
201
+ self.refresh()
202
+
203
+ def render(self) -> Text:
204
+ width = self.size.width or 24
205
+ divider_width = max(width - 1, 1)
206
+ text = Text()
207
+ text.append("Files\n", style="bold #928374")
208
+ text.append("─" * divider_width + "\n", style="#504945")
209
+
210
+ if not self._files:
211
+ text.append("(none yet)", style="#504945 italic")
212
+ else:
213
+ # Show last 20 files
214
+ for action, path in self._files[-20:]:
215
+ display_path = path
216
+ if "/" in display_path:
217
+ parts = display_path.split("/")
218
+ display_path = "/".join(parts[-2:])
219
+ max_len = max(width - 4, 1)
220
+ if len(display_path) > max_len:
221
+ if max_len > 3:
222
+ display_path = display_path[: max_len - 3] + "..."
223
+ else:
224
+ display_path = display_path[:max_len]
225
+ icon = "✏️" if action == "write" else "📖"
226
+ color = "#b8bb26" if action == "write" else "#83a598"
227
+ text.append(f"{icon} ", style=color)
228
+ text.append(f"{display_path}\n", style="#ebdbb2")
229
+
230
+ return text
231
+
232
+
233
+ class ErrorPanelWidget(Static):
234
+ """Dedicated panel for showing current error prominently."""
235
+
236
+ error: reactive[str | None] = reactive(None)
237
+ details: reactive[str | None] = reactive(None)
238
+
239
+ def render(self) -> Panel | Text:
240
+ if not self.error:
241
+ return Text("")
242
+
243
+ # Build error content
244
+ content = Text()
245
+ content.append(self.error, style="bold #fb4934")
246
+
247
+ if self.details:
248
+ # Truncate details if too long
249
+ details = self.details
250
+ max_lines = 5
251
+ lines = details.split("\n")
252
+ if len(lines) > max_lines:
253
+ details = "\n".join(lines[:max_lines]) + "\n..."
254
+
255
+ content.append("\n\n", style="")
256
+ content.append(details, style="#ebdbb2")
257
+
258
+ return Panel(
259
+ content,
260
+ title="[bold #fb4934]Error[/]",
261
+ border_style="#cc241d",
262
+ padding=(0, 1),
263
+ )
@@ -0,0 +1,5 @@
1
+ """Validation system."""
2
+
3
+ from galangal.validation.runner import ValidationResult, ValidationRunner
4
+
5
+ __all__ = ["ValidationRunner", "ValidationResult"]