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
@@ -0,0 +1,530 @@
1
+ """
2
+ galangal start - Start a new task.
3
+ """
4
+
5
+ import argparse
6
+ import threading
7
+
8
+ from galangal.core.state import (
9
+ TaskType,
10
+ WorkflowState,
11
+ get_task_dir,
12
+ load_state,
13
+ save_state,
14
+ )
15
+ from galangal.core.tasks import (
16
+ create_task_branch,
17
+ generate_unique_task_name,
18
+ is_on_base_branch,
19
+ is_valid_task_name,
20
+ pull_base_branch,
21
+ set_active_task,
22
+ switch_to_base_branch,
23
+ task_name_exists,
24
+ )
25
+ from galangal.core.utils import debug_exception, debug_log
26
+ from galangal.core.workflow import run_workflow
27
+ from galangal.ui.tui import PromptType, WorkflowTUIApp
28
+
29
+
30
+ def _check_config_updates() -> bool:
31
+ """Check for missing config sections and prompt user.
32
+
33
+ Returns True if user wants to continue, False if they want to quit and configure.
34
+ """
35
+ try:
36
+ import yaml
37
+ from rich.prompt import Confirm
38
+
39
+ from galangal.commands.init_wizard import check_missing_sections
40
+ from galangal.config.loader import get_project_root
41
+ from galangal.ui.console import console, print_info
42
+
43
+ config_path = get_project_root() / ".galangal" / "config.yaml"
44
+ if not config_path.exists():
45
+ return True
46
+
47
+ existing_config = yaml.safe_load(config_path.read_text())
48
+ if not existing_config:
49
+ return True
50
+
51
+ missing = check_missing_sections(existing_config)
52
+ if not missing:
53
+ return True
54
+
55
+ # Show message about new config options
56
+ console.print()
57
+ print_info(f"New config options available: [cyan]{', '.join(missing)}[/cyan]")
58
+ console.print("[dim] Run 'galangal init' to configure them.[/dim]\n")
59
+
60
+ # Ask if they want to continue or quit to configure
61
+ continue_anyway = Confirm.ask(
62
+ "Continue without configuring?",
63
+ default=True,
64
+ )
65
+
66
+ if not continue_anyway:
67
+ console.print("\n[dim]Run 'galangal init' to configure new options.[/dim]")
68
+ return False
69
+
70
+ console.print() # Add spacing before TUI starts
71
+ return True
72
+
73
+ except Exception:
74
+ # Non-critical, don't interrupt task creation
75
+ return True
76
+
77
+
78
+ def create_task(
79
+ task_name: str,
80
+ description: str,
81
+ task_type: TaskType,
82
+ github_issue: int | None = None,
83
+ github_repo: str | None = None,
84
+ screenshots: list[str] | None = None,
85
+ ) -> tuple[bool, str]:
86
+ """Create a new task with the given name, description, and type.
87
+
88
+ Args:
89
+ task_name: Name for the task (will be used for directory and branch)
90
+ description: Task description
91
+ task_type: Type of task (Feature, Bug Fix, etc.)
92
+ github_issue: Optional GitHub issue number this task is linked to
93
+ github_repo: Optional GitHub repo (owner/repo) for the issue
94
+ screenshots: Optional list of local screenshot paths from the issue
95
+
96
+ Returns:
97
+ Tuple of (success, message)
98
+ """
99
+ # Check if task already exists
100
+ if task_name_exists(task_name):
101
+ return False, f"Task '{task_name}' already exists"
102
+
103
+ task_dir = get_task_dir(task_name)
104
+
105
+ # Create git branch
106
+ success, msg = create_task_branch(task_name)
107
+ if not success and "already exists" not in msg.lower():
108
+ return False, msg
109
+
110
+ # Create task directory
111
+ task_dir.mkdir(parents=True, exist_ok=True)
112
+ (task_dir / "logs").mkdir(exist_ok=True)
113
+
114
+ # Initialize state with task type and optional GitHub info
115
+ state = WorkflowState.new(
116
+ description, task_name, task_type, github_issue, github_repo, screenshots
117
+ )
118
+ save_state(state)
119
+
120
+ # Set as active task
121
+ set_active_task(task_name)
122
+
123
+ return True, f"Created task: {task_name}"
124
+
125
+
126
+ def cmd_start(args: argparse.Namespace) -> int:
127
+ """Start a new task."""
128
+ from galangal.config.loader import require_initialized
129
+
130
+ if not require_initialized():
131
+ return 1
132
+
133
+ # Check for new/missing config sections - prompt user if any found
134
+ if not _check_config_updates():
135
+ return 0 # User chose to quit and configure
136
+
137
+ description = " ".join(args.description) if args.description else ""
138
+ task_name = args.name or ""
139
+ from_issue = getattr(args, "issue", None)
140
+
141
+ # Create TUI app for task setup
142
+ app = WorkflowTUIApp("New Task", "SETUP", hidden_stages=frozenset())
143
+
144
+ task_info = {
145
+ "type": None,
146
+ "description": description,
147
+ "name": task_name,
148
+ "github_issue": from_issue,
149
+ "github_repo": None,
150
+ "screenshots": None,
151
+ }
152
+ result_code = {"value": 0}
153
+
154
+ def task_creation_thread():
155
+ try:
156
+ app.add_activity("[bold]Starting new task...[/bold]", "🆕")
157
+
158
+ # Check if on base branch before starting
159
+ on_base, current_branch, base_branch = is_on_base_branch()
160
+ if not on_base:
161
+ app.set_status("setup", "checking branch")
162
+ app.add_activity(
163
+ f"Currently on branch '{current_branch}', expected '{base_branch}'",
164
+ "⚠️",
165
+ )
166
+
167
+ branch_event = threading.Event()
168
+ branch_result = {"value": None}
169
+
170
+ def handle_branch_choice(choice):
171
+ branch_result["value"] = choice
172
+ branch_event.set()
173
+
174
+ app.show_prompt(
175
+ PromptType.YES_NO,
176
+ f"Switch to '{base_branch}' branch before creating task?",
177
+ handle_branch_choice,
178
+ )
179
+ branch_event.wait()
180
+
181
+ if branch_result["value"] == "yes":
182
+ success, message = switch_to_base_branch()
183
+ if success:
184
+ app.add_activity(f"Switched to '{base_branch}' branch", "✓")
185
+ # Pull latest changes after switching
186
+ app.set_status("setup", "pulling latest")
187
+ pull_success, pull_msg = pull_base_branch()
188
+ if pull_success:
189
+ app.add_activity(f"Pulled latest from '{base_branch}'", "✓")
190
+ else:
191
+ app.add_activity(f"Pull failed: {pull_msg}", "⚠️")
192
+ # Non-fatal - warn but continue
193
+ app.show_message(f"Warning: {pull_msg}", "warning")
194
+ else:
195
+ app.add_activity(f"Failed to switch branch: {message}", "✗")
196
+ app.show_message(
197
+ f"Could not switch to {base_branch}: {message}",
198
+ "error",
199
+ )
200
+ app._workflow_result = "error"
201
+ result_code["value"] = 1
202
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
203
+ return
204
+ else:
205
+ # User chose not to switch - continue on current branch
206
+ app.add_activity(
207
+ f"Continuing on '{current_branch}' branch",
208
+ "ℹ️",
209
+ )
210
+ else:
211
+ # Already on base branch - pull latest changes
212
+ app.set_status("setup", "pulling latest")
213
+ pull_success, pull_msg = pull_base_branch()
214
+ if pull_success:
215
+ app.add_activity(f"Pulled latest from '{base_branch}'", "✓")
216
+ else:
217
+ app.add_activity(f"Pull failed: {pull_msg}", "⚠️")
218
+ # Non-fatal - warn but continue
219
+ app.show_message(f"Warning: {pull_msg}", "warning")
220
+
221
+ # Step 0: Choose task source (manual or GitHub) if no description/issue provided
222
+ if not task_info["description"] and not task_info["github_issue"]:
223
+ app.set_status("setup", "select task source")
224
+
225
+ source_event = threading.Event()
226
+ source_result = {"value": None}
227
+
228
+ def handle_source(choice):
229
+ source_result["value"] = choice
230
+ source_event.set()
231
+
232
+ app.show_prompt(
233
+ PromptType.TASK_SOURCE,
234
+ "Create task from:",
235
+ handle_source,
236
+ )
237
+ source_event.wait()
238
+
239
+ if source_result["value"] == "quit":
240
+ app._workflow_result = "cancelled"
241
+ result_code["value"] = 1
242
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
243
+ return
244
+
245
+ if source_result["value"] == "github":
246
+ # Handle GitHub issue selection
247
+ app.set_status("setup", "checking GitHub")
248
+ app.show_message("Checking GitHub setup...", "info")
249
+
250
+ try:
251
+ from galangal.github.client import ensure_github_ready
252
+ from galangal.github.issues import list_issues
253
+
254
+ check = ensure_github_ready()
255
+ if not check:
256
+ app.show_message(
257
+ "GitHub not ready. Run 'galangal github check'", "error"
258
+ )
259
+ app._workflow_result = "error"
260
+ result_code["value"] = 1
261
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
262
+ return
263
+
264
+ task_info["github_repo"] = check.repo_name
265
+
266
+ # List issues with galangal label
267
+ app.set_status("setup", "fetching issues")
268
+ app.show_message("Fetching issues...", "info")
269
+
270
+ issues = list_issues()
271
+ if not issues:
272
+ app.show_message("No issues with 'galangal' label found", "warning")
273
+ app._workflow_result = "cancelled"
274
+ result_code["value"] = 1
275
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
276
+ return
277
+
278
+ # Show issue selection
279
+ app.set_status("setup", "select issue")
280
+ issue_event = threading.Event()
281
+ issue_result = {"value": None}
282
+
283
+ def handle_issue(issue_num):
284
+ issue_result["value"] = issue_num
285
+ issue_event.set()
286
+
287
+ issue_options = [(i.number, i.title) for i in issues]
288
+ app.show_github_issue_select(issue_options, handle_issue)
289
+ issue_event.wait()
290
+
291
+ if issue_result["value"] is None:
292
+ app._workflow_result = "cancelled"
293
+ result_code["value"] = 1
294
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
295
+ return
296
+
297
+ # Get the selected issue details
298
+ selected_issue = next(
299
+ (i for i in issues if i.number == issue_result["value"]), None
300
+ )
301
+ if selected_issue:
302
+ task_info["github_issue"] = selected_issue.number
303
+ task_info["description"] = (
304
+ f"{selected_issue.title}\n\n{selected_issue.body}"
305
+ )
306
+ app.show_message(f"Selected issue #{selected_issue.number}", "success")
307
+
308
+ # Download screenshots from issue body
309
+ from galangal.github.images import extract_image_urls
310
+
311
+ images = extract_image_urls(selected_issue.body)
312
+ if images:
313
+ app.set_status("setup", "downloading screenshots")
314
+ app.show_message(
315
+ f"Found {len(images)} screenshot(s) in issue...", "info"
316
+ )
317
+ # Note: Actual download happens after task_name is generated
318
+ # Store the issue body for later processing
319
+ task_info["_issue_body"] = selected_issue.body
320
+
321
+ # Try to infer task type from labels
322
+ type_hint = selected_issue.get_task_type_hint()
323
+ if type_hint:
324
+ task_info["type"] = TaskType.from_str(type_hint)
325
+ app.show_message(
326
+ f"Inferred type from labels: {task_info['type'].display_name()}",
327
+ "info",
328
+ )
329
+
330
+ except Exception as e:
331
+ debug_exception("GitHub integration failed", e)
332
+ app.show_message(f"GitHub error: {e}", "error")
333
+ app._workflow_result = "error"
334
+ result_code["value"] = 1
335
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
336
+ return
337
+
338
+ # Step 1: Get task type (if not already set from GitHub labels)
339
+ if task_info["type"] is None:
340
+ app.set_status("setup", "select task type")
341
+
342
+ type_event = threading.Event()
343
+ type_result = {"value": None}
344
+
345
+ def handle_type(choice):
346
+ type_result["value"] = choice
347
+ type_event.set()
348
+
349
+ app.show_prompt(
350
+ PromptType.TASK_TYPE,
351
+ "Select task type:",
352
+ handle_type,
353
+ )
354
+ type_event.wait()
355
+
356
+ if type_result["value"] == "quit":
357
+ app._workflow_result = "cancelled"
358
+ result_code["value"] = 1
359
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
360
+ return
361
+
362
+ # Map selection to TaskType
363
+ task_info["type"] = TaskType.from_str(type_result["value"])
364
+
365
+ app.show_message(f"Task type: {task_info['type'].display_name()}", "success")
366
+
367
+ # Step 2: Get task description if not provided
368
+ if not task_info["description"]:
369
+ app.set_status("setup", "enter description")
370
+ desc_event = threading.Event()
371
+
372
+ def handle_description(desc):
373
+ task_info["description"] = desc
374
+ desc_event.set()
375
+
376
+ app.show_multiline_input(
377
+ "Enter task description (Ctrl+S to submit):", "", handle_description
378
+ )
379
+ desc_event.wait()
380
+
381
+ if not task_info["description"]:
382
+ app.show_message("Task description required", "error")
383
+ app._workflow_result = "cancelled"
384
+ result_code["value"] = 1
385
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
386
+ return
387
+
388
+ # Step 3: Generate task name if not provided
389
+ if not task_info["name"]:
390
+ app.set_status("setup", "generating task name")
391
+ app.show_message("Generating task name...", "info")
392
+
393
+ # Use prefix for GitHub issues
394
+ prefix = f"issue-{task_info['github_issue']}" if task_info["github_issue"] else None
395
+ task_info["name"] = generate_unique_task_name(task_info["description"], prefix)
396
+ else:
397
+ # Validate provided name for safety (prevent shell injection)
398
+ valid, error_msg = is_valid_task_name(task_info["name"])
399
+ if not valid:
400
+ app.show_message(f"Invalid task name: {error_msg}", "error")
401
+ app._workflow_result = "cancelled"
402
+ result_code["value"] = 1
403
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
404
+ return
405
+
406
+ # Check if name already exists
407
+ if task_name_exists(task_info["name"]):
408
+ app.show_message(f"Task '{task_info['name']}' already exists", "error")
409
+ app._workflow_result = "cancelled"
410
+ result_code["value"] = 1
411
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
412
+ return
413
+
414
+ app.show_message(f"Task name: {task_info['name']}", "success")
415
+ debug_log("Task name generated", name=task_info["name"])
416
+
417
+ # Step 4: Create the task (must happen BEFORE screenshot download
418
+ # because download_issue_screenshots creates the task directory)
419
+ app.set_status("setup", "creating task")
420
+ debug_log("Creating task", name=task_info["name"], type=str(task_info["type"]))
421
+ success, message = create_task(
422
+ task_info["name"],
423
+ task_info["description"],
424
+ task_info["type"],
425
+ github_issue=task_info["github_issue"],
426
+ github_repo=task_info["github_repo"],
427
+ )
428
+
429
+ if success:
430
+ app.show_message(message, "success")
431
+ app._workflow_result = "task_created"
432
+ debug_log("Task created successfully", name=task_info["name"])
433
+
434
+ # Step 4.5: Download screenshots if from GitHub issue
435
+ # (must happen AFTER task creation since it writes to task directory)
436
+ if task_info.get("_issue_body"):
437
+ app.set_status("setup", "downloading screenshots")
438
+ issue_body = task_info["_issue_body"]
439
+ debug_log(
440
+ "Starting screenshot download",
441
+ body_length=len(issue_body),
442
+ body_preview=issue_body[:200] if issue_body else "empty",
443
+ )
444
+ try:
445
+ from galangal.github.issues import download_issue_screenshots
446
+
447
+ task_dir = get_task_dir(task_info["name"])
448
+ screenshot_paths = download_issue_screenshots(
449
+ task_info["_issue_body"],
450
+ task_dir,
451
+ )
452
+ if screenshot_paths:
453
+ task_info["screenshots"] = screenshot_paths
454
+ app.show_message(
455
+ f"Downloaded {len(screenshot_paths)} screenshot(s)", "success"
456
+ )
457
+ debug_log("Screenshots downloaded", count=len(screenshot_paths))
458
+
459
+ # Update state with screenshot paths
460
+ state = load_state(task_info["name"])
461
+ if state:
462
+ state.screenshots = screenshot_paths
463
+ save_state(state)
464
+ except Exception as e:
465
+ debug_exception("Screenshot download failed", e)
466
+ app.show_message(f"Screenshot download failed: {e}", "warning")
467
+ # Non-critical - continue without screenshots
468
+
469
+ # Mark issue as in-progress if from GitHub
470
+ if task_info["github_issue"]:
471
+ try:
472
+ from galangal.github.issues import mark_issue_in_progress
473
+
474
+ mark_issue_in_progress(task_info["github_issue"])
475
+ app.show_message("Marked issue as in-progress", "info")
476
+ except Exception as e:
477
+ debug_exception("Failed to mark issue as in-progress", e)
478
+ # Non-critical - continue anyway
479
+ else:
480
+ app.show_message(f"Failed: {message}", "error")
481
+ app._workflow_result = "error"
482
+ result_code["value"] = 1
483
+
484
+ except Exception as e:
485
+ debug_exception("Task creation failed", e)
486
+ app.show_message(f"Error: {e}", "error")
487
+ app._workflow_result = "error"
488
+ result_code["value"] = 1
489
+ finally:
490
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
491
+
492
+ # Start creation in background thread
493
+ thread = threading.Thread(target=task_creation_thread, daemon=True)
494
+ app.call_later(thread.start)
495
+ app.run()
496
+
497
+ # Log the TUI result for debugging
498
+ debug_log(
499
+ "TUI app exited",
500
+ result=getattr(app, "_workflow_result", "unknown"),
501
+ task_name=task_info.get("name", "none"),
502
+ result_code=result_code["value"],
503
+ )
504
+
505
+ # If task was created, start the workflow
506
+ if app._workflow_result == "task_created" and task_info["name"]:
507
+ debug_log("Task created, loading state", task=task_info["name"])
508
+ state = load_state(task_info["name"])
509
+ if state:
510
+ # Pass skip_discovery flag via state attribute
511
+ if getattr(args, "skip_discovery", False):
512
+ state._skip_discovery = True
513
+ try:
514
+ debug_log("Starting workflow", task=task_info["name"])
515
+ run_workflow(state)
516
+ except Exception as e:
517
+ debug_exception("Workflow failed to start", e)
518
+ from galangal.ui.console import print_error
519
+
520
+ print_error(f"Workflow failed: {e}")
521
+ return 1
522
+ else:
523
+ debug_log("Failed to load state", task=task_info["name"])
524
+ else:
525
+ debug_log(
526
+ "Not starting workflow",
527
+ reason=f"result={getattr(app, '_workflow_result', 'unknown')}, name={task_info.get('name', 'none')}",
528
+ )
529
+
530
+ return result_code["value"]
@@ -0,0 +1,44 @@
1
+ """
2
+ galangal status - Show active task status.
3
+ """
4
+
5
+ import argparse
6
+
7
+ from galangal.core.artifacts import artifact_exists
8
+ from galangal.core.tasks import get_active_task
9
+ from galangal.ui.console import display_status, print_error, print_info
10
+
11
+
12
+ def cmd_status(args: argparse.Namespace) -> int:
13
+ """Show status of active task."""
14
+ from galangal.config.loader import require_initialized
15
+ from galangal.core.state import get_all_artifact_names, load_state
16
+
17
+ if not require_initialized():
18
+ return 1
19
+
20
+ active = get_active_task()
21
+ if not active:
22
+ print_info("No active task. Use 'list' to see tasks, 'switch' to select one.")
23
+ return 0
24
+
25
+ state = load_state(active)
26
+ if state is None:
27
+ print_error(f"Could not load state for '{active}'.")
28
+ return 1
29
+
30
+ # Collect artifact status - derived from STAGE_METADATA
31
+ artifacts = [(name, artifact_exists(name, active)) for name in get_all_artifact_names()]
32
+
33
+ display_status(
34
+ task_name=active,
35
+ stage=state.stage,
36
+ task_type=state.task_type,
37
+ attempt=state.attempt,
38
+ awaiting_approval=state.awaiting_approval,
39
+ last_failure=state.last_failure,
40
+ description=state.task_description,
41
+ artifacts=artifacts,
42
+ )
43
+
44
+ return 0
@@ -0,0 +1,28 @@
1
+ """
2
+ galangal switch - Switch to a different task.
3
+ """
4
+
5
+ import argparse
6
+
7
+ from galangal.core.state import get_task_dir, load_state
8
+ from galangal.core.tasks import set_active_task
9
+ from galangal.ui.console import console, print_error, print_success
10
+
11
+
12
+ def cmd_switch(args: argparse.Namespace) -> int:
13
+ """Switch to a different task."""
14
+ task_name = args.task_name
15
+ task_dir = get_task_dir(task_name)
16
+
17
+ if not task_dir.exists():
18
+ print_error(f"Task '{task_name}' not found.")
19
+ return 1
20
+
21
+ set_active_task(task_name)
22
+ state = load_state(task_name)
23
+ if state:
24
+ print_success(f"Switched to: {task_name}")
25
+ console.print(f"[dim]Stage:[/dim] {state.stage.value}")
26
+ console.print(f"[dim]Type:[/dim] {state.task_type.display_name()}")
27
+ console.print(f"[dim]Description:[/dim] {state.task_description[:60]}...")
28
+ return 0
@@ -0,0 +1,15 @@
1
+ """Configuration management."""
2
+
3
+ from galangal.config.loader import get_config, get_project_root, load_config
4
+ from galangal.config.schema import GalangalConfig, ProjectConfig, StageConfig
5
+ from galangal.exceptions import ConfigError
6
+
7
+ __all__ = [
8
+ "ConfigError",
9
+ "load_config",
10
+ "get_project_root",
11
+ "get_config",
12
+ "GalangalConfig",
13
+ "ProjectConfig",
14
+ "StageConfig",
15
+ ]