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/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Galangal Orchestrate - AI-driven development workflow orchestrator.
|
|
3
|
+
|
|
4
|
+
A deterministic workflow system that guides AI assistants through
|
|
5
|
+
structured development stages: PM -> DESIGN -> DEV -> TEST -> QA -> REVIEW -> DOCS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from galangal.exceptions import (
|
|
9
|
+
ConfigError,
|
|
10
|
+
GalangalError,
|
|
11
|
+
TaskError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
WorkflowError,
|
|
14
|
+
)
|
|
15
|
+
from galangal.logging import (
|
|
16
|
+
WorkflowLogger,
|
|
17
|
+
configure_logging,
|
|
18
|
+
get_logger,
|
|
19
|
+
workflow_logger,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.13.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
# Exceptions
|
|
26
|
+
"GalangalError",
|
|
27
|
+
"ConfigError",
|
|
28
|
+
"ValidationError",
|
|
29
|
+
"WorkflowError",
|
|
30
|
+
"TaskError",
|
|
31
|
+
# Logging
|
|
32
|
+
"configure_logging",
|
|
33
|
+
"get_logger",
|
|
34
|
+
"WorkflowLogger",
|
|
35
|
+
"workflow_logger",
|
|
36
|
+
]
|
galangal/__main__.py
ADDED
galangal/ai/__init__.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""AI backend abstractions and factory functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from galangal.ai.base import AIBackend
|
|
9
|
+
from galangal.ai.claude import ClaudeBackend
|
|
10
|
+
from galangal.ai.codex import CodexBackend
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from galangal.config.schema import AIBackendConfig, GalangalConfig
|
|
14
|
+
from galangal.core.state import Stage
|
|
15
|
+
|
|
16
|
+
# Registry of available backends
|
|
17
|
+
BACKEND_REGISTRY: dict[str, type[AIBackend]] = {
|
|
18
|
+
"claude": ClaudeBackend,
|
|
19
|
+
"codex": CodexBackend,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Default fallback chain: backend -> fallback
|
|
23
|
+
DEFAULT_FALLBACKS: dict[str, str] = {
|
|
24
|
+
"codex": "claude",
|
|
25
|
+
"gemini": "claude",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_backend(
|
|
30
|
+
name: str,
|
|
31
|
+
config: GalangalConfig | None = None,
|
|
32
|
+
) -> AIBackend:
|
|
33
|
+
"""
|
|
34
|
+
Factory function to instantiate backends by name.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
name: Backend name (e.g., "claude", "codex")
|
|
38
|
+
config: Optional project config to get backend-specific settings
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Instantiated backend with configuration
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: If backend name is unknown
|
|
45
|
+
"""
|
|
46
|
+
backend_class = BACKEND_REGISTRY.get(name.lower())
|
|
47
|
+
if not backend_class:
|
|
48
|
+
available = list(BACKEND_REGISTRY.keys())
|
|
49
|
+
raise ValueError(f"Unknown backend: {name}. Available: {available}")
|
|
50
|
+
|
|
51
|
+
# Get backend-specific config if available
|
|
52
|
+
backend_config: AIBackendConfig | None = None
|
|
53
|
+
if config:
|
|
54
|
+
backend_config = config.ai.backends.get(name.lower())
|
|
55
|
+
|
|
56
|
+
return backend_class(backend_config)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_backend_available(
|
|
60
|
+
name: str,
|
|
61
|
+
config: GalangalConfig | None = None,
|
|
62
|
+
) -> bool:
|
|
63
|
+
"""
|
|
64
|
+
Check if a backend's CLI tool is available on the system.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
name: Backend name (e.g., "claude", "codex")
|
|
68
|
+
config: Optional project config to get custom command names
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True if the backend's CLI is installed and accessible
|
|
72
|
+
"""
|
|
73
|
+
# Check config for custom command name
|
|
74
|
+
cmd: str | None
|
|
75
|
+
if config and name.lower() in config.ai.backends:
|
|
76
|
+
cmd = config.ai.backends[name.lower()].command
|
|
77
|
+
else:
|
|
78
|
+
# Fallback to default command names
|
|
79
|
+
cli_commands = {
|
|
80
|
+
"claude": "claude",
|
|
81
|
+
"codex": "codex",
|
|
82
|
+
"gemini": "gemini", # Future
|
|
83
|
+
}
|
|
84
|
+
cmd = cli_commands.get(name.lower())
|
|
85
|
+
|
|
86
|
+
if not cmd:
|
|
87
|
+
return False
|
|
88
|
+
return shutil.which(cmd) is not None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_backend_with_fallback(
|
|
92
|
+
name: str,
|
|
93
|
+
fallbacks: dict[str, str] | None = None,
|
|
94
|
+
config: GalangalConfig | None = None,
|
|
95
|
+
) -> AIBackend:
|
|
96
|
+
"""
|
|
97
|
+
Get a backend, falling back to alternatives if unavailable.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
name: Primary backend name
|
|
101
|
+
fallbacks: Optional custom fallback mapping. Defaults to DEFAULT_FALLBACKS.
|
|
102
|
+
config: Optional project config for backend settings
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The requested backend if available, otherwise the fallback backend
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
ValueError: If neither primary nor fallback backends are available
|
|
109
|
+
"""
|
|
110
|
+
fallbacks = fallbacks or DEFAULT_FALLBACKS
|
|
111
|
+
|
|
112
|
+
if is_backend_available(name, config):
|
|
113
|
+
return get_backend(name, config)
|
|
114
|
+
|
|
115
|
+
# Try fallback
|
|
116
|
+
fallback_name = fallbacks.get(name.lower())
|
|
117
|
+
if fallback_name and is_backend_available(fallback_name, config):
|
|
118
|
+
return get_backend(fallback_name, config)
|
|
119
|
+
|
|
120
|
+
# Last resort: try claude if it exists
|
|
121
|
+
if name.lower() != "claude" and is_backend_available("claude", config):
|
|
122
|
+
return get_backend("claude", config)
|
|
123
|
+
|
|
124
|
+
raise ValueError(f"Backend '{name}' not available and no fallback found")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_backend_for_stage(
|
|
128
|
+
stage: Stage,
|
|
129
|
+
config: GalangalConfig,
|
|
130
|
+
use_fallback: bool = True,
|
|
131
|
+
) -> AIBackend:
|
|
132
|
+
"""
|
|
133
|
+
Get the appropriate backend for a specific stage.
|
|
134
|
+
|
|
135
|
+
Checks config.ai.stage_backends for stage-specific overrides,
|
|
136
|
+
otherwise uses config.ai.default.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
stage: The workflow stage
|
|
140
|
+
config: Project configuration
|
|
141
|
+
use_fallback: If True, fall back to alternative backends if primary unavailable
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The configured backend for the stage
|
|
145
|
+
"""
|
|
146
|
+
# Check for stage-specific backend override
|
|
147
|
+
stage_key = stage.value.upper()
|
|
148
|
+
if stage_key in config.ai.stage_backends:
|
|
149
|
+
backend_name = config.ai.stage_backends[stage_key]
|
|
150
|
+
else:
|
|
151
|
+
backend_name = config.ai.default
|
|
152
|
+
|
|
153
|
+
if use_fallback:
|
|
154
|
+
return get_backend_with_fallback(backend_name, config=config)
|
|
155
|
+
return get_backend(backend_name, config)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = [
|
|
159
|
+
"AIBackend",
|
|
160
|
+
"ClaudeBackend",
|
|
161
|
+
"CodexBackend",
|
|
162
|
+
"BACKEND_REGISTRY",
|
|
163
|
+
"get_backend",
|
|
164
|
+
"get_backend_for_stage",
|
|
165
|
+
"get_backend_with_fallback",
|
|
166
|
+
"is_backend_available",
|
|
167
|
+
]
|
galangal/ai/base.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Abstract base class for AI backends.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from collections.abc import Callable, Generator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from galangal.results import StageResult
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from galangal.config.schema import AIBackendConfig
|
|
18
|
+
from galangal.ui.tui import StageUI
|
|
19
|
+
|
|
20
|
+
# Type alias for pause check callback
|
|
21
|
+
PauseCheck = Callable[[], bool]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AIBackend(ABC):
|
|
25
|
+
"""Abstract base class for AI backends."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: AIBackendConfig | None = None):
|
|
28
|
+
"""
|
|
29
|
+
Initialize the backend with optional configuration.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
config: Backend-specific configuration from config.ai.backends.
|
|
33
|
+
If None, backend should use sensible defaults.
|
|
34
|
+
"""
|
|
35
|
+
self._config = config
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def config(self) -> AIBackendConfig | None:
|
|
39
|
+
"""Return the backend configuration."""
|
|
40
|
+
return self._config
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def read_only(self) -> bool:
|
|
44
|
+
"""Return whether this backend runs in read-only mode."""
|
|
45
|
+
return self._config.read_only if self._config else False
|
|
46
|
+
|
|
47
|
+
def _substitute_placeholders(self, args: list[str], **kwargs: str | int) -> list[str]:
|
|
48
|
+
"""
|
|
49
|
+
Substitute placeholders in command arguments.
|
|
50
|
+
|
|
51
|
+
Replaces {placeholder} patterns with provided values.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
args: List of argument strings with optional placeholders
|
|
55
|
+
**kwargs: Placeholder values (e.g., max_turns=200, schema_file="/tmp/s.json")
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
List of arguments with placeholders replaced
|
|
59
|
+
"""
|
|
60
|
+
result = []
|
|
61
|
+
for arg in args:
|
|
62
|
+
for key, value in kwargs.items():
|
|
63
|
+
arg = arg.replace(f"{{{key}}}", str(value))
|
|
64
|
+
result.append(arg)
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def _temp_file(
|
|
69
|
+
self,
|
|
70
|
+
content: str | None = None,
|
|
71
|
+
suffix: str = ".txt",
|
|
72
|
+
) -> Generator[str, None, None]:
|
|
73
|
+
"""
|
|
74
|
+
Context manager for temporary file creation with automatic cleanup.
|
|
75
|
+
|
|
76
|
+
Creates a temporary file, optionally writes content to it, yields the path,
|
|
77
|
+
and ensures cleanup on exit (even if an exception occurs).
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
content: Optional content to write to the file. If None, creates an
|
|
81
|
+
empty file (useful for output files written by external processes).
|
|
82
|
+
suffix: File suffix (default: ".txt")
|
|
83
|
+
|
|
84
|
+
Yields:
|
|
85
|
+
Path to the temporary file
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
with self._temp_file(prompt, suffix=".txt") as prompt_file:
|
|
89
|
+
shell_cmd = f"cat '{prompt_file}' | claude ..."
|
|
90
|
+
# File is automatically cleaned up after the block
|
|
91
|
+
"""
|
|
92
|
+
filepath: str | None = None
|
|
93
|
+
try:
|
|
94
|
+
if content is not None:
|
|
95
|
+
# Create file with content
|
|
96
|
+
with tempfile.NamedTemporaryFile(
|
|
97
|
+
mode="w", suffix=suffix, delete=False, encoding="utf-8"
|
|
98
|
+
) as f:
|
|
99
|
+
f.write(content)
|
|
100
|
+
filepath = f.name
|
|
101
|
+
else:
|
|
102
|
+
# Create empty file for external process to write
|
|
103
|
+
fd, filepath = tempfile.mkstemp(suffix=suffix)
|
|
104
|
+
os.close(fd)
|
|
105
|
+
yield filepath
|
|
106
|
+
finally:
|
|
107
|
+
if filepath and os.path.exists(filepath):
|
|
108
|
+
try:
|
|
109
|
+
os.unlink(filepath)
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def invoke(
|
|
115
|
+
self,
|
|
116
|
+
prompt: str,
|
|
117
|
+
timeout: int = 14400,
|
|
118
|
+
max_turns: int = 200,
|
|
119
|
+
ui: StageUI | None = None,
|
|
120
|
+
pause_check: PauseCheck | None = None,
|
|
121
|
+
stage: str | None = None,
|
|
122
|
+
log_file: str | None = None,
|
|
123
|
+
) -> StageResult:
|
|
124
|
+
"""
|
|
125
|
+
Invoke the AI with a prompt for a full stage execution.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
prompt: The full prompt to send
|
|
129
|
+
timeout: Maximum time in seconds
|
|
130
|
+
max_turns: Maximum conversation turns
|
|
131
|
+
ui: Optional TUI for progress display
|
|
132
|
+
pause_check: Optional callback that returns True if pause requested
|
|
133
|
+
stage: Optional stage name for backends that customize behavior per stage
|
|
134
|
+
log_file: Optional path to log file for streaming output
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
StageResult with success/failure and structured outcome type
|
|
138
|
+
"""
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def generate_text(self, prompt: str, timeout: int = 30) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Simple text generation (for PR titles, commit messages, task names).
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
prompt: The prompt to send
|
|
148
|
+
timeout: Maximum time in seconds
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Generated text, or empty string on failure
|
|
152
|
+
"""
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
@abstractmethod
|
|
157
|
+
def name(self) -> str:
|
|
158
|
+
"""Return the backend name."""
|
|
159
|
+
pass
|