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.

Files changed (49) hide show
  1. galangal/__init__.py +8 -0
  2. galangal/__main__.py +6 -0
  3. galangal/ai/__init__.py +6 -0
  4. galangal/ai/base.py +55 -0
  5. galangal/ai/claude.py +278 -0
  6. galangal/ai/gemini.py +38 -0
  7. galangal/cli.py +296 -0
  8. galangal/commands/__init__.py +42 -0
  9. galangal/commands/approve.py +187 -0
  10. galangal/commands/complete.py +268 -0
  11. galangal/commands/init.py +173 -0
  12. galangal/commands/list.py +20 -0
  13. galangal/commands/pause.py +40 -0
  14. galangal/commands/prompts.py +98 -0
  15. galangal/commands/reset.py +43 -0
  16. galangal/commands/resume.py +29 -0
  17. galangal/commands/skip.py +216 -0
  18. galangal/commands/start.py +144 -0
  19. galangal/commands/status.py +62 -0
  20. galangal/commands/switch.py +28 -0
  21. galangal/config/__init__.py +13 -0
  22. galangal/config/defaults.py +133 -0
  23. galangal/config/loader.py +113 -0
  24. galangal/config/schema.py +155 -0
  25. galangal/core/__init__.py +18 -0
  26. galangal/core/artifacts.py +66 -0
  27. galangal/core/state.py +248 -0
  28. galangal/core/tasks.py +170 -0
  29. galangal/core/workflow.py +835 -0
  30. galangal/prompts/__init__.py +5 -0
  31. galangal/prompts/builder.py +166 -0
  32. galangal/prompts/defaults/design.md +54 -0
  33. galangal/prompts/defaults/dev.md +39 -0
  34. galangal/prompts/defaults/docs.md +46 -0
  35. galangal/prompts/defaults/pm.md +75 -0
  36. galangal/prompts/defaults/qa.md +49 -0
  37. galangal/prompts/defaults/review.md +65 -0
  38. galangal/prompts/defaults/security.md +68 -0
  39. galangal/prompts/defaults/test.md +59 -0
  40. galangal/ui/__init__.py +5 -0
  41. galangal/ui/console.py +123 -0
  42. galangal/ui/tui.py +1065 -0
  43. galangal/validation/__init__.py +5 -0
  44. galangal/validation/runner.py +395 -0
  45. galangal_orchestrate-0.2.11.dist-info/METADATA +278 -0
  46. galangal_orchestrate-0.2.11.dist-info/RECORD +49 -0
  47. galangal_orchestrate-0.2.11.dist-info/WHEEL +4 -0
  48. galangal_orchestrate-0.2.11.dist-info/entry_points.txt +2 -0
  49. 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"