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.
- galangal/__init__.py +36 -0
- galangal/__main__.py +6 -0
- galangal/ai/__init__.py +167 -0
- galangal/ai/base.py +159 -0
- galangal/ai/claude.py +352 -0
- galangal/ai/codex.py +370 -0
- galangal/ai/gemini.py +43 -0
- galangal/ai/subprocess.py +254 -0
- galangal/cli.py +371 -0
- galangal/commands/__init__.py +27 -0
- galangal/commands/complete.py +367 -0
- galangal/commands/github.py +355 -0
- galangal/commands/init.py +177 -0
- galangal/commands/init_wizard.py +762 -0
- galangal/commands/list.py +20 -0
- galangal/commands/pause.py +34 -0
- galangal/commands/prompts.py +89 -0
- galangal/commands/reset.py +41 -0
- galangal/commands/resume.py +30 -0
- galangal/commands/skip.py +62 -0
- galangal/commands/start.py +530 -0
- galangal/commands/status.py +44 -0
- galangal/commands/switch.py +28 -0
- galangal/config/__init__.py +15 -0
- galangal/config/defaults.py +183 -0
- galangal/config/loader.py +163 -0
- galangal/config/schema.py +330 -0
- galangal/core/__init__.py +33 -0
- galangal/core/artifacts.py +136 -0
- galangal/core/state.py +1097 -0
- galangal/core/tasks.py +454 -0
- galangal/core/utils.py +116 -0
- galangal/core/workflow/__init__.py +68 -0
- galangal/core/workflow/core.py +789 -0
- galangal/core/workflow/engine.py +781 -0
- galangal/core/workflow/pause.py +35 -0
- galangal/core/workflow/tui_runner.py +1322 -0
- galangal/exceptions.py +36 -0
- galangal/github/__init__.py +31 -0
- galangal/github/client.py +427 -0
- galangal/github/images.py +324 -0
- galangal/github/issues.py +298 -0
- galangal/logging.py +364 -0
- galangal/prompts/__init__.py +5 -0
- galangal/prompts/builder.py +527 -0
- galangal/prompts/defaults/benchmark.md +34 -0
- galangal/prompts/defaults/contract.md +35 -0
- galangal/prompts/defaults/design.md +54 -0
- galangal/prompts/defaults/dev.md +89 -0
- galangal/prompts/defaults/docs.md +104 -0
- galangal/prompts/defaults/migration.md +59 -0
- galangal/prompts/defaults/pm.md +110 -0
- galangal/prompts/defaults/pm_questions.md +53 -0
- galangal/prompts/defaults/preflight.md +32 -0
- galangal/prompts/defaults/qa.md +65 -0
- galangal/prompts/defaults/review.md +90 -0
- galangal/prompts/defaults/review_codex.md +99 -0
- galangal/prompts/defaults/security.md +84 -0
- galangal/prompts/defaults/test.md +91 -0
- galangal/results.py +176 -0
- galangal/ui/__init__.py +5 -0
- galangal/ui/console.py +126 -0
- galangal/ui/tui/__init__.py +56 -0
- galangal/ui/tui/adapters.py +168 -0
- galangal/ui/tui/app.py +902 -0
- galangal/ui/tui/entry.py +24 -0
- galangal/ui/tui/mixins.py +196 -0
- galangal/ui/tui/modals.py +339 -0
- galangal/ui/tui/styles/app.tcss +86 -0
- galangal/ui/tui/styles/modals.tcss +197 -0
- galangal/ui/tui/types.py +107 -0
- galangal/ui/tui/widgets.py +263 -0
- galangal/validation/__init__.py +5 -0
- galangal/validation/runner.py +1072 -0
- galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
- galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
- galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
- galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
- 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 ""
|