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
galangal/ai/claude.py ADDED
@@ -0,0 +1,352 @@
1
+ """
2
+ Claude CLI backend implementation.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import subprocess
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from galangal.ai.base import AIBackend, PauseCheck
12
+ from galangal.ai.subprocess import SubprocessRunner
13
+ from galangal.config.loader import get_project_root
14
+ from galangal.logging import get_logger
15
+ from galangal.results import StageResult
16
+
17
+ if TYPE_CHECKING:
18
+ from galangal.ui.tui import StageUI
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ class ClaudeBackend(AIBackend):
24
+ """Claude CLI backend."""
25
+
26
+ # Default command and args when no config provided
27
+ DEFAULT_COMMAND = "claude"
28
+ DEFAULT_ARGS = [
29
+ "--output-format",
30
+ "stream-json",
31
+ "--verbose",
32
+ "--max-turns",
33
+ "{max_turns}",
34
+ "--permission-mode",
35
+ "bypassPermissions",
36
+ ]
37
+
38
+ @property
39
+ def name(self) -> str:
40
+ return "claude"
41
+
42
+ def _build_command(self, prompt_file: str, max_turns: int) -> str:
43
+ """
44
+ Build the shell command to invoke Claude.
45
+
46
+ Uses config.command and config.args if available, otherwise falls back
47
+ to hard-coded defaults for backwards compatibility.
48
+
49
+ Args:
50
+ prompt_file: Path to temp file containing the prompt
51
+ max_turns: Maximum conversation turns
52
+
53
+ Returns:
54
+ Shell command string ready for subprocess
55
+ """
56
+ if self._config:
57
+ command = self._config.command
58
+ args = self._substitute_placeholders(
59
+ self._config.args,
60
+ max_turns=max_turns,
61
+ )
62
+ else:
63
+ # Backwards compatibility: use defaults
64
+ command = self.DEFAULT_COMMAND
65
+ args = self._substitute_placeholders(
66
+ self.DEFAULT_ARGS,
67
+ max_turns=max_turns,
68
+ )
69
+
70
+ args_str = " ".join(args)
71
+ return f"cat '{prompt_file}' | {command} {args_str}"
72
+
73
+ def invoke(
74
+ self,
75
+ prompt: str,
76
+ timeout: int = 14400,
77
+ max_turns: int = 200,
78
+ ui: StageUI | None = None,
79
+ pause_check: PauseCheck | None = None,
80
+ stage: str | None = None,
81
+ log_file: str | None = None,
82
+ ) -> StageResult:
83
+ """Invoke Claude Code with a prompt."""
84
+ # State for output processing
85
+ pending_tools: list[tuple[str, str]] = []
86
+ log_handle = None
87
+
88
+ # Open log file for streaming if provided
89
+ if log_file:
90
+ try:
91
+ log_handle = open(log_file, "a")
92
+ except OSError as e:
93
+ logger.warning("failed_to_open_log_file", path=log_file, error=str(e))
94
+
95
+ def should_log_line(line: str) -> bool:
96
+ """Determine if a line should be logged (errors, warnings, results)."""
97
+ if not line.strip():
98
+ return False
99
+ try:
100
+ data = json.loads(line.strip())
101
+ msg_type = data.get("type", "")
102
+
103
+ # Always log errors and results
104
+ if msg_type in ("error", "result"):
105
+ return True
106
+
107
+ # Log system messages (rate limiting, etc.)
108
+ if msg_type == "system":
109
+ return True
110
+
111
+ # Log tool errors
112
+ if msg_type == "user":
113
+ content = data.get("message", {}).get("content", [])
114
+ for item in content:
115
+ if item.get("type") == "tool_result" and item.get("is_error"):
116
+ return True
117
+
118
+ return False
119
+ except (json.JSONDecodeError, KeyError, TypeError):
120
+ # Log non-JSON lines that look like errors
121
+ lower = line.lower()
122
+ return "error" in lower or "warning" in lower or "failed" in lower
123
+
124
+ def on_output(line: str) -> None:
125
+ """Process each output line."""
126
+ if ui:
127
+ ui.add_raw_line(line)
128
+ # Only log errors, warnings, and results to file
129
+ if log_handle and should_log_line(line):
130
+ try:
131
+ log_handle.write(line + "\n")
132
+ log_handle.flush() # Ensure immediate write
133
+ except OSError:
134
+ pass
135
+ self._process_stream_line(line, ui, pending_tools)
136
+
137
+ def on_idle(elapsed: float) -> None:
138
+ """Update status when idle."""
139
+ if ui:
140
+ if pending_tools:
141
+ tool_name = pending_tools[-1][1]
142
+ ui.set_status("waiting", f"{tool_name}...")
143
+ else:
144
+ ui.set_status("waiting", "API response")
145
+
146
+ try:
147
+ with self._temp_file(prompt, suffix=".txt") as prompt_file:
148
+ shell_cmd = self._build_command(prompt_file, max_turns)
149
+
150
+ if ui:
151
+ ui.set_status("starting", "initializing Claude")
152
+
153
+ runner = SubprocessRunner(
154
+ command=shell_cmd,
155
+ timeout=timeout,
156
+ pause_check=pause_check,
157
+ ui=ui,
158
+ on_output=on_output,
159
+ on_idle=on_idle,
160
+ idle_interval=3.0,
161
+ poll_interval_active=0.05,
162
+ poll_interval_idle=0.5,
163
+ )
164
+
165
+ result = runner.run()
166
+
167
+ if result.paused:
168
+ if ui:
169
+ ui.finish(success=False)
170
+ return StageResult.paused()
171
+
172
+ if result.timed_out:
173
+ return StageResult.timeout(result.timeout_seconds or timeout)
174
+
175
+ # Process completed - analyze output
176
+ full_output = result.output
177
+
178
+ if "max turns" in full_output.lower() or "reached max" in full_output.lower():
179
+ if ui:
180
+ ui.add_activity("Max turns reached", "❌")
181
+ return StageResult.max_turns(full_output)
182
+
183
+ # Extract result from JSON stream
184
+ result_text = ""
185
+ for line in full_output.splitlines():
186
+ if not line.strip():
187
+ continue
188
+ try:
189
+ data = json.loads(line.strip())
190
+ if data.get("type") == "result":
191
+ result_text = data.get("result", "")
192
+ if ui:
193
+ ui.set_turns(data.get("num_turns", 0))
194
+ break
195
+ except json.JSONDecodeError as e:
196
+ logger.debug("json_decode_error", error=str(e), line=line[:100])
197
+ except (KeyError, TypeError):
198
+ pass
199
+
200
+ if result.exit_code == 0:
201
+ return StageResult.create_success(
202
+ message=result_text or "Stage completed",
203
+ output=full_output,
204
+ )
205
+ return StageResult.error(
206
+ message=f"Claude failed (exit {result.exit_code})",
207
+ output=full_output,
208
+ )
209
+
210
+ except Exception as e:
211
+ return StageResult.error(f"Claude invocation error: {e}")
212
+ finally:
213
+ # Close log file handle
214
+ if log_handle:
215
+ try:
216
+ log_handle.close()
217
+ except OSError:
218
+ pass
219
+
220
+ def _process_stream_line(
221
+ self,
222
+ line: str,
223
+ ui: StageUI | None,
224
+ pending_tools: list[tuple[str, str]],
225
+ ) -> None:
226
+ """Process a single line of streaming output."""
227
+ if not line.strip():
228
+ return
229
+
230
+ try:
231
+ data = json.loads(line.strip())
232
+ msg_type = data.get("type", "")
233
+
234
+ if msg_type == "assistant" and "tool_use" in str(data):
235
+ self._handle_assistant_message(data, ui, pending_tools)
236
+ elif msg_type == "user":
237
+ self._handle_user_message(data, ui, pending_tools)
238
+ elif msg_type == "system":
239
+ self._handle_system_message(data, ui)
240
+
241
+ except json.JSONDecodeError as e:
242
+ logger.debug("json_decode_error", error=str(e), line=line[:100])
243
+ except (KeyError, TypeError):
244
+ pass
245
+
246
+ def _handle_assistant_message(
247
+ self,
248
+ data: dict[str, Any],
249
+ ui: StageUI | None,
250
+ pending_tools: list[tuple[str, str]],
251
+ ) -> None:
252
+ """Handle assistant message with tool use."""
253
+ content = data.get("message", {}).get("content", [])
254
+
255
+ for item in content:
256
+ if item.get("type") == "tool_use":
257
+ tool_name = item.get("name", "")
258
+ tool_id = item.get("id", "")
259
+ if tool_id:
260
+ pending_tools.append((tool_id, tool_name))
261
+
262
+ if ui:
263
+ if tool_name in ["Write", "Edit"]:
264
+ tool_input = item.get("input", {})
265
+ file_path = tool_input.get("file_path", "") or tool_input.get("path", "")
266
+ if file_path:
267
+ short_path = file_path.split("/")[-1] if "/" in file_path else file_path
268
+ ui.add_activity(f"{tool_name}: {short_path}", "✏️")
269
+ ui.set_status("writing", short_path)
270
+
271
+ elif tool_name == "Read":
272
+ tool_input = item.get("input", {})
273
+ file_path = tool_input.get("file_path", "") or tool_input.get("path", "")
274
+ if file_path:
275
+ short_path = file_path.split("/")[-1] if "/" in file_path else file_path
276
+ ui.add_activity(f"Read: {short_path}", "📖")
277
+ ui.set_status("reading", short_path)
278
+
279
+ elif tool_name == "Bash":
280
+ cmd_preview = item.get("input", {}).get("command", "")[:140]
281
+ ui.add_activity(f"Bash: {cmd_preview}", "🔧")
282
+ ui.set_status("running", "bash")
283
+
284
+ elif tool_name in ["Grep", "Glob"]:
285
+ pattern = item.get("input", {}).get("pattern", "")[:80]
286
+ ui.add_activity(f"{tool_name}: {pattern}", "🔍")
287
+ ui.set_status("searching", pattern[:40])
288
+
289
+ elif tool_name == "Task":
290
+ desc = item.get("input", {}).get("description", "agent")
291
+ ui.add_activity(f"Task: {desc}", "🤖")
292
+ ui.set_status("agent", desc[:25])
293
+
294
+ elif tool_name not in ["TodoWrite"]:
295
+ ui.add_activity(f"{tool_name}", "⚡")
296
+ ui.set_status("executing", tool_name)
297
+
298
+ elif item.get("type") == "thinking":
299
+ if ui:
300
+ ui.set_status("thinking")
301
+
302
+ def _handle_user_message(
303
+ self,
304
+ data: dict[str, Any],
305
+ ui: StageUI | None,
306
+ pending_tools: list[tuple[str, str]],
307
+ ) -> None:
308
+ """Handle user message with tool results."""
309
+ content = data.get("message", {}).get("content", [])
310
+
311
+ for item in content:
312
+ if item.get("type") == "tool_result":
313
+ tool_id = item.get("tool_use_id", "")
314
+ is_error = item.get("is_error", False)
315
+ pending_tools[:] = [(tid, tname) for tid, tname in pending_tools if tid != tool_id]
316
+ if is_error and ui:
317
+ ui.set_status("error", "tool failed")
318
+
319
+ def _handle_system_message(self, data: dict[str, Any], ui: StageUI | None) -> None:
320
+ """Handle system messages."""
321
+ message = data.get("message", "")
322
+ subtype = data.get("subtype", "")
323
+
324
+ if "rate" in message.lower():
325
+ if ui:
326
+ ui.add_activity("Rate limited - waiting", "🚦")
327
+ ui.set_status("rate_limited", "waiting...")
328
+ elif subtype and ui:
329
+ ui.set_status(subtype)
330
+
331
+ def generate_text(self, prompt: str, timeout: int = 30) -> str:
332
+ """Simple text generation."""
333
+ try:
334
+ with self._temp_file(prompt, suffix=".txt") as prompt_file:
335
+ # Use config command or default
336
+ command = self._config.command if self._config else self.DEFAULT_COMMAND
337
+
338
+ # Pipe file content to claude via stdin (simple text output mode)
339
+ shell_cmd = f"cat '{prompt_file}' | {command} --output-format text"
340
+ result = subprocess.run(
341
+ shell_cmd,
342
+ shell=True,
343
+ cwd=get_project_root(),
344
+ capture_output=True,
345
+ text=True,
346
+ timeout=timeout,
347
+ )
348
+ if result.returncode == 0 and result.stdout.strip():
349
+ return result.stdout.strip()
350
+ except (subprocess.TimeoutExpired, Exception):
351
+ pass
352
+ return ""