galangal-orchestrate 0.2.11__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.
Potentially problematic release.
This version of galangal-orchestrate might be problematic. Click here for more details.
- galangal/__init__.py +8 -0
- galangal/__main__.py +6 -0
- galangal/ai/__init__.py +6 -0
- galangal/ai/base.py +55 -0
- galangal/ai/claude.py +278 -0
- galangal/ai/gemini.py +38 -0
- galangal/cli.py +296 -0
- galangal/commands/__init__.py +42 -0
- galangal/commands/approve.py +187 -0
- galangal/commands/complete.py +268 -0
- galangal/commands/init.py +173 -0
- galangal/commands/list.py +20 -0
- galangal/commands/pause.py +40 -0
- galangal/commands/prompts.py +98 -0
- galangal/commands/reset.py +43 -0
- galangal/commands/resume.py +29 -0
- galangal/commands/skip.py +216 -0
- galangal/commands/start.py +144 -0
- galangal/commands/status.py +62 -0
- galangal/commands/switch.py +28 -0
- galangal/config/__init__.py +13 -0
- galangal/config/defaults.py +133 -0
- galangal/config/loader.py +113 -0
- galangal/config/schema.py +155 -0
- galangal/core/__init__.py +18 -0
- galangal/core/artifacts.py +66 -0
- galangal/core/state.py +248 -0
- galangal/core/tasks.py +170 -0
- galangal/core/workflow.py +835 -0
- galangal/prompts/__init__.py +5 -0
- galangal/prompts/builder.py +166 -0
- galangal/prompts/defaults/design.md +54 -0
- galangal/prompts/defaults/dev.md +39 -0
- galangal/prompts/defaults/docs.md +46 -0
- galangal/prompts/defaults/pm.md +75 -0
- galangal/prompts/defaults/qa.md +49 -0
- galangal/prompts/defaults/review.md +65 -0
- galangal/prompts/defaults/security.md +68 -0
- galangal/prompts/defaults/test.md +59 -0
- galangal/ui/__init__.py +5 -0
- galangal/ui/console.py +123 -0
- galangal/ui/tui.py +1065 -0
- galangal/validation/__init__.py +5 -0
- galangal/validation/runner.py +395 -0
- galangal_orchestrate-0.2.11.dist-info/METADATA +278 -0
- galangal_orchestrate-0.2.11.dist-info/RECORD +49 -0
- galangal_orchestrate-0.2.11.dist-info/WHEEL +4 -0
- galangal_orchestrate-0.2.11.dist-info/entry_points.txt +2 -0
- galangal_orchestrate-0.2.11.dist-info/licenses/LICENSE +674 -0
galangal/core/state.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow state management - Stage, TaskType, and WorkflowState.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskType(str, Enum):
|
|
14
|
+
"""Type of task - determines which stages are required."""
|
|
15
|
+
|
|
16
|
+
FEATURE = "feature"
|
|
17
|
+
BUG_FIX = "bug_fix"
|
|
18
|
+
REFACTOR = "refactor"
|
|
19
|
+
CHORE = "chore"
|
|
20
|
+
DOCS = "docs"
|
|
21
|
+
HOTFIX = "hotfix"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_str(cls, value: str) -> "TaskType":
|
|
25
|
+
"""Convert string to TaskType, defaulting to FEATURE."""
|
|
26
|
+
try:
|
|
27
|
+
return cls(value.lower())
|
|
28
|
+
except ValueError:
|
|
29
|
+
return cls.FEATURE
|
|
30
|
+
|
|
31
|
+
def display_name(self) -> str:
|
|
32
|
+
"""Human-readable name for display."""
|
|
33
|
+
return {
|
|
34
|
+
TaskType.FEATURE: "Feature",
|
|
35
|
+
TaskType.BUG_FIX: "Bug Fix",
|
|
36
|
+
TaskType.REFACTOR: "Refactor",
|
|
37
|
+
TaskType.CHORE: "Chore",
|
|
38
|
+
TaskType.DOCS: "Docs",
|
|
39
|
+
TaskType.HOTFIX: "Hotfix",
|
|
40
|
+
}[self]
|
|
41
|
+
|
|
42
|
+
def description(self) -> str:
|
|
43
|
+
"""Short description of this task type."""
|
|
44
|
+
return {
|
|
45
|
+
TaskType.FEATURE: "New functionality (full workflow)",
|
|
46
|
+
TaskType.BUG_FIX: "Fix broken behavior (skip design)",
|
|
47
|
+
TaskType.REFACTOR: "Restructure code (skip design, security)",
|
|
48
|
+
TaskType.CHORE: "Dependencies, config, tooling",
|
|
49
|
+
TaskType.DOCS: "Documentation only (minimal stages)",
|
|
50
|
+
TaskType.HOTFIX: "Critical fix (expedited)",
|
|
51
|
+
}[self]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Stage(str, Enum):
|
|
55
|
+
"""Workflow stages."""
|
|
56
|
+
|
|
57
|
+
PM = "PM"
|
|
58
|
+
DESIGN = "DESIGN"
|
|
59
|
+
PREFLIGHT = "PREFLIGHT"
|
|
60
|
+
DEV = "DEV"
|
|
61
|
+
MIGRATION = "MIGRATION"
|
|
62
|
+
TEST = "TEST"
|
|
63
|
+
CONTRACT = "CONTRACT"
|
|
64
|
+
QA = "QA"
|
|
65
|
+
BENCHMARK = "BENCHMARK"
|
|
66
|
+
SECURITY = "SECURITY"
|
|
67
|
+
REVIEW = "REVIEW"
|
|
68
|
+
DOCS = "DOCS"
|
|
69
|
+
COMPLETE = "COMPLETE"
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_str(cls, value: str) -> "Stage":
|
|
73
|
+
return cls(value.upper())
|
|
74
|
+
|
|
75
|
+
def is_conditional(self) -> bool:
|
|
76
|
+
"""Return True if this stage only runs when conditions are met."""
|
|
77
|
+
return self in (Stage.MIGRATION, Stage.CONTRACT, Stage.BENCHMARK)
|
|
78
|
+
|
|
79
|
+
def is_skippable(self) -> bool:
|
|
80
|
+
"""Return True if this stage can be manually skipped."""
|
|
81
|
+
return self in (
|
|
82
|
+
Stage.DESIGN,
|
|
83
|
+
Stage.MIGRATION,
|
|
84
|
+
Stage.CONTRACT,
|
|
85
|
+
Stage.BENCHMARK,
|
|
86
|
+
Stage.SECURITY,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Stage order - the canonical sequence
|
|
91
|
+
STAGE_ORDER = [
|
|
92
|
+
Stage.PM,
|
|
93
|
+
Stage.DESIGN,
|
|
94
|
+
Stage.PREFLIGHT,
|
|
95
|
+
Stage.DEV,
|
|
96
|
+
Stage.MIGRATION,
|
|
97
|
+
Stage.TEST,
|
|
98
|
+
Stage.CONTRACT,
|
|
99
|
+
Stage.QA,
|
|
100
|
+
Stage.BENCHMARK,
|
|
101
|
+
Stage.SECURITY,
|
|
102
|
+
Stage.REVIEW,
|
|
103
|
+
Stage.DOCS,
|
|
104
|
+
Stage.COMPLETE,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Stages that are always skipped for each task type
|
|
109
|
+
TASK_TYPE_SKIP_STAGES: dict[TaskType, set[Stage]] = {
|
|
110
|
+
TaskType.FEATURE: set(), # Full workflow
|
|
111
|
+
TaskType.BUG_FIX: {Stage.DESIGN, Stage.BENCHMARK},
|
|
112
|
+
TaskType.REFACTOR: {
|
|
113
|
+
Stage.DESIGN,
|
|
114
|
+
Stage.MIGRATION,
|
|
115
|
+
Stage.CONTRACT,
|
|
116
|
+
Stage.BENCHMARK,
|
|
117
|
+
Stage.SECURITY,
|
|
118
|
+
},
|
|
119
|
+
TaskType.CHORE: {Stage.DESIGN, Stage.MIGRATION, Stage.CONTRACT, Stage.BENCHMARK},
|
|
120
|
+
TaskType.DOCS: {
|
|
121
|
+
Stage.DESIGN,
|
|
122
|
+
Stage.PREFLIGHT,
|
|
123
|
+
Stage.MIGRATION,
|
|
124
|
+
Stage.TEST,
|
|
125
|
+
Stage.CONTRACT,
|
|
126
|
+
Stage.QA,
|
|
127
|
+
Stage.BENCHMARK,
|
|
128
|
+
Stage.SECURITY,
|
|
129
|
+
},
|
|
130
|
+
TaskType.HOTFIX: {Stage.DESIGN, Stage.BENCHMARK},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def should_skip_for_task_type(stage: Stage, task_type: TaskType) -> bool:
|
|
135
|
+
"""Check if a stage should be skipped based on task type."""
|
|
136
|
+
return stage in TASK_TYPE_SKIP_STAGES.get(task_type, set())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_hidden_stages_for_task_type(task_type: TaskType, config_skip: list[str] = None) -> set[str]:
|
|
140
|
+
"""Get stages to hide from progress bar based on task type and config.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
task_type: The type of task being executed
|
|
144
|
+
config_skip: List of stage names from config.stages.skip
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Set of stage name strings that should be hidden from the progress bar
|
|
148
|
+
"""
|
|
149
|
+
hidden = set()
|
|
150
|
+
|
|
151
|
+
# Add task type skips
|
|
152
|
+
for stage in TASK_TYPE_SKIP_STAGES.get(task_type, set()):
|
|
153
|
+
hidden.add(stage.value)
|
|
154
|
+
|
|
155
|
+
# Add config skips
|
|
156
|
+
if config_skip:
|
|
157
|
+
for stage_name in config_skip:
|
|
158
|
+
hidden.add(stage_name.upper())
|
|
159
|
+
|
|
160
|
+
return hidden
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class WorkflowState:
|
|
165
|
+
"""Persistent workflow state for a task."""
|
|
166
|
+
|
|
167
|
+
stage: Stage
|
|
168
|
+
attempt: int
|
|
169
|
+
awaiting_approval: bool
|
|
170
|
+
clarification_required: bool
|
|
171
|
+
last_failure: Optional[str]
|
|
172
|
+
started_at: str
|
|
173
|
+
task_description: str
|
|
174
|
+
task_name: str
|
|
175
|
+
task_type: TaskType = TaskType.FEATURE
|
|
176
|
+
|
|
177
|
+
def to_dict(self) -> dict:
|
|
178
|
+
d = asdict(self)
|
|
179
|
+
d["stage"] = self.stage.value
|
|
180
|
+
d["task_type"] = self.task_type.value
|
|
181
|
+
return d
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_dict(cls, d: dict) -> "WorkflowState":
|
|
185
|
+
return cls(
|
|
186
|
+
stage=Stage.from_str(d["stage"]),
|
|
187
|
+
attempt=d.get("attempt", 1),
|
|
188
|
+
awaiting_approval=d.get("awaiting_approval", False),
|
|
189
|
+
clarification_required=d.get("clarification_required", False),
|
|
190
|
+
last_failure=d.get("last_failure"),
|
|
191
|
+
started_at=d.get("started_at", datetime.now(timezone.utc).isoformat()),
|
|
192
|
+
task_description=d.get("task_description", ""),
|
|
193
|
+
task_name=d.get("task_name", ""),
|
|
194
|
+
task_type=TaskType.from_str(d.get("task_type", "feature")),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def new(
|
|
199
|
+
cls, description: str, task_name: str, task_type: TaskType = TaskType.FEATURE
|
|
200
|
+
) -> "WorkflowState":
|
|
201
|
+
return cls(
|
|
202
|
+
stage=Stage.PM,
|
|
203
|
+
attempt=1,
|
|
204
|
+
awaiting_approval=False,
|
|
205
|
+
clarification_required=False,
|
|
206
|
+
last_failure=None,
|
|
207
|
+
started_at=datetime.now(timezone.utc).isoformat(),
|
|
208
|
+
task_description=description,
|
|
209
|
+
task_name=task_name,
|
|
210
|
+
task_type=task_type,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_task_dir(task_name: str) -> Path:
|
|
215
|
+
"""Get the directory for a task."""
|
|
216
|
+
from galangal.config.loader import get_tasks_dir
|
|
217
|
+
|
|
218
|
+
return get_tasks_dir() / task_name
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def load_state(task_name: Optional[str] = None) -> Optional[WorkflowState]:
|
|
222
|
+
"""Load workflow state for a task."""
|
|
223
|
+
from galangal.core.tasks import get_active_task
|
|
224
|
+
|
|
225
|
+
if task_name is None:
|
|
226
|
+
task_name = get_active_task()
|
|
227
|
+
if task_name is None:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
state_file = get_task_dir(task_name) / "state.json"
|
|
231
|
+
if not state_file.exists():
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
with open(state_file) as f:
|
|
236
|
+
return WorkflowState.from_dict(json.load(f))
|
|
237
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
238
|
+
print(f"Error loading state: {e}")
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def save_state(state: WorkflowState) -> None:
|
|
243
|
+
"""Save workflow state for a task."""
|
|
244
|
+
task_dir = get_task_dir(state.task_name)
|
|
245
|
+
task_dir.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
state_file = task_dir / "state.json"
|
|
247
|
+
with open(state_file, "w") as f:
|
|
248
|
+
json.dump(state.to_dict(), f, indent=2)
|
galangal/core/tasks.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Task directory management - creating, listing, and switching tasks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from galangal.config.loader import (
|
|
13
|
+
get_project_root,
|
|
14
|
+
get_tasks_dir,
|
|
15
|
+
get_done_dir,
|
|
16
|
+
get_active_file,
|
|
17
|
+
get_config,
|
|
18
|
+
)
|
|
19
|
+
from galangal.core.artifacts import run_command
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_active_task() -> Optional[str]:
|
|
23
|
+
"""Get the currently active task name."""
|
|
24
|
+
active_file = get_active_file()
|
|
25
|
+
if active_file.exists():
|
|
26
|
+
return active_file.read_text().strip()
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_active_task(task_name: str) -> None:
|
|
31
|
+
"""Set the active task."""
|
|
32
|
+
tasks_dir = get_tasks_dir()
|
|
33
|
+
tasks_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
get_active_file().write_text(task_name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clear_active_task() -> None:
|
|
38
|
+
"""Clear the active task."""
|
|
39
|
+
active_file = get_active_file()
|
|
40
|
+
if active_file.exists():
|
|
41
|
+
active_file.unlink()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_task_dir(task_name: str) -> Path:
|
|
45
|
+
"""Get the directory for a task."""
|
|
46
|
+
return get_tasks_dir() / task_name
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def list_tasks() -> list[tuple[str, str, str, str]]:
|
|
50
|
+
"""List all tasks. Returns [(name, stage, task_type, description), ...]."""
|
|
51
|
+
tasks = []
|
|
52
|
+
tasks_dir = get_tasks_dir()
|
|
53
|
+
if not tasks_dir.exists():
|
|
54
|
+
return tasks
|
|
55
|
+
|
|
56
|
+
for task_dir in tasks_dir.iterdir():
|
|
57
|
+
if (
|
|
58
|
+
task_dir.is_dir()
|
|
59
|
+
and not task_dir.name.startswith(".")
|
|
60
|
+
and task_dir.name != "done"
|
|
61
|
+
):
|
|
62
|
+
state_file = task_dir / "state.json"
|
|
63
|
+
if state_file.exists():
|
|
64
|
+
try:
|
|
65
|
+
with open(state_file) as f:
|
|
66
|
+
data = json.load(f)
|
|
67
|
+
tasks.append(
|
|
68
|
+
(
|
|
69
|
+
task_dir.name,
|
|
70
|
+
data.get("stage", "?"),
|
|
71
|
+
data.get("task_type", "feature"),
|
|
72
|
+
data.get("task_description", "")[:50],
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
except (json.JSONDecodeError, KeyError):
|
|
76
|
+
tasks.append((task_dir.name, "?", "?", "(invalid state)"))
|
|
77
|
+
return sorted(tasks)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def generate_task_name_ai(description: str) -> Optional[str]:
|
|
81
|
+
"""Use AI to generate a concise, meaningful task name."""
|
|
82
|
+
prompt = f"""Generate a short task name for this description. Rules:
|
|
83
|
+
- 2-4 words, kebab-case (e.g., fix-auth-bug, add-user-export)
|
|
84
|
+
- No prefix, just the name itself
|
|
85
|
+
- Capture the essence of the task
|
|
86
|
+
- Use action verbs (fix, add, update, refactor, implement)
|
|
87
|
+
|
|
88
|
+
Description: {description}
|
|
89
|
+
|
|
90
|
+
Reply with ONLY the task name, nothing else."""
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
["claude", "-p", prompt, "--max-turns", "1"],
|
|
95
|
+
capture_output=True,
|
|
96
|
+
text=True,
|
|
97
|
+
timeout=30,
|
|
98
|
+
cwd=get_project_root(),
|
|
99
|
+
)
|
|
100
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
101
|
+
# Clean the response - extract just the task name
|
|
102
|
+
name = result.stdout.strip().lower()
|
|
103
|
+
# Remove any quotes, backticks, or extra text
|
|
104
|
+
name = re.sub(r"[`\"']", "", name)
|
|
105
|
+
# Take only first line if multiple
|
|
106
|
+
name = name.split("\n")[0].strip()
|
|
107
|
+
# Validate it looks like a task name (kebab-case, reasonable length)
|
|
108
|
+
if re.match(r"^[a-z][a-z0-9-]{2,40}$", name) and name.count("-") <= 5:
|
|
109
|
+
return name
|
|
110
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
|
111
|
+
pass
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def generate_task_name_fallback(description: str) -> str:
|
|
116
|
+
"""Fallback: Generate task name from description using simple word extraction."""
|
|
117
|
+
words = description.lower().split()[:4]
|
|
118
|
+
cleaned = [re.sub(r"[^a-z0-9]", "", w) for w in words]
|
|
119
|
+
cleaned = [w for w in cleaned if w]
|
|
120
|
+
name = "-".join(cleaned)
|
|
121
|
+
return name if name else f"task-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def generate_task_name(description: str) -> str:
|
|
125
|
+
"""Generate a task name from description using AI with fallback."""
|
|
126
|
+
# Try AI-generated name first
|
|
127
|
+
ai_name = generate_task_name_ai(description)
|
|
128
|
+
if ai_name:
|
|
129
|
+
return ai_name
|
|
130
|
+
|
|
131
|
+
# Fallback to simple extraction
|
|
132
|
+
return generate_task_name_fallback(description)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def task_name_exists(name: str) -> bool:
|
|
136
|
+
"""Check if task name exists in active or done folders."""
|
|
137
|
+
return get_task_dir(name).exists() or (get_done_dir() / name).exists()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def create_task_branch(task_name: str) -> tuple[bool, str]:
|
|
141
|
+
"""Create a git branch for the task."""
|
|
142
|
+
config = get_config()
|
|
143
|
+
branch_name = config.branch_pattern.format(task_name=task_name)
|
|
144
|
+
|
|
145
|
+
# Check if branch already exists
|
|
146
|
+
code, out, _ = run_command(["git", "branch", "--list", branch_name])
|
|
147
|
+
if out.strip():
|
|
148
|
+
return True, f"Branch {branch_name} already exists"
|
|
149
|
+
|
|
150
|
+
# Create and checkout new branch
|
|
151
|
+
code, out, err = run_command(["git", "checkout", "-b", branch_name])
|
|
152
|
+
if code != 0:
|
|
153
|
+
return False, f"Failed to create branch: {err}"
|
|
154
|
+
|
|
155
|
+
return True, f"Created branch: {branch_name}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_current_branch() -> str:
|
|
159
|
+
"""Get the current git branch name."""
|
|
160
|
+
try:
|
|
161
|
+
result = subprocess.run(
|
|
162
|
+
["git", "branch", "--show-current"],
|
|
163
|
+
cwd=get_project_root(),
|
|
164
|
+
capture_output=True,
|
|
165
|
+
text=True,
|
|
166
|
+
timeout=5,
|
|
167
|
+
)
|
|
168
|
+
return result.stdout.strip() or "unknown"
|
|
169
|
+
except Exception:
|
|
170
|
+
return "unknown"
|