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/__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
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m galangal`."""
2
+
3
+ from galangal.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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