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
@@ -0,0 +1,835 @@
1
+ """
2
+ Workflow execution - stage execution, rollback, loop handling.
3
+ """
4
+
5
+ import signal
6
+ from datetime import datetime, timezone
7
+ from typing import Optional
8
+
9
+ from rich.console import Console
10
+
11
+ from galangal.config.loader import get_config, get_tasks_dir
12
+ from galangal.core.artifacts import artifact_exists, read_artifact, write_artifact, artifact_path
13
+ from galangal.core.state import (
14
+ Stage,
15
+ WorkflowState,
16
+ STAGE_ORDER,
17
+ save_state,
18
+ get_task_dir,
19
+ should_skip_for_task_type,
20
+ get_hidden_stages_for_task_type,
21
+ )
22
+ from galangal.core.tasks import get_current_branch
23
+ from galangal.ai.claude import set_pause_requested, get_pause_requested
24
+ from galangal.prompts.builder import PromptBuilder
25
+ from galangal.validation.runner import ValidationRunner
26
+ from galangal.ui.tui import run_stage_with_tui, TUIAdapter, WorkflowTUIApp, PromptType
27
+ from galangal.ai.claude import ClaudeBackend
28
+
29
+ console = Console()
30
+
31
+ # Global state for pause handling
32
+ _current_state: Optional[WorkflowState] = None
33
+
34
+
35
+ def _signal_handler(signum: int, frame) -> None:
36
+ """Handle SIGINT (Ctrl+C) gracefully."""
37
+ set_pause_requested(True)
38
+ console.print(
39
+ "\n\n[yellow]⏸️ Pause requested - finishing current operation...[/yellow]"
40
+ )
41
+ console.print("[dim] (Press Ctrl+C again to force quit)[/dim]\n")
42
+
43
+
44
+ def get_next_stage(
45
+ current: Stage, state: WorkflowState
46
+ ) -> Optional[Stage]:
47
+ """Get the next stage, handling conditional stages and task type skipping."""
48
+ config = get_config()
49
+ task_name = state.task_name
50
+ task_type = state.task_type
51
+ idx = STAGE_ORDER.index(current)
52
+
53
+ if idx >= len(STAGE_ORDER) - 1:
54
+ return None
55
+
56
+ next_stage = STAGE_ORDER[idx + 1]
57
+
58
+ # Check config-level skipping
59
+ if next_stage.value in [s.upper() for s in config.stages.skip]:
60
+ return get_next_stage(next_stage, state)
61
+
62
+ # Check task type skipping
63
+ if should_skip_for_task_type(next_stage, task_type):
64
+ return get_next_stage(next_stage, state)
65
+
66
+ # Check conditional stages via validation runner
67
+ runner = ValidationRunner()
68
+ if next_stage == Stage.MIGRATION:
69
+ if artifact_exists("MIGRATION_SKIP.md", task_name):
70
+ return get_next_stage(next_stage, state)
71
+ # Check skip condition
72
+ result = runner.validate_stage("MIGRATION", task_name)
73
+ if result.message.endswith("(condition met)"):
74
+ return get_next_stage(next_stage, state)
75
+
76
+ elif next_stage == Stage.CONTRACT:
77
+ if artifact_exists("CONTRACT_SKIP.md", task_name):
78
+ return get_next_stage(next_stage, state)
79
+ result = runner.validate_stage("CONTRACT", task_name)
80
+ if result.message.endswith("(condition met)"):
81
+ return get_next_stage(next_stage, state)
82
+
83
+ elif next_stage == Stage.BENCHMARK:
84
+ if artifact_exists("BENCHMARK_SKIP.md", task_name):
85
+ return get_next_stage(next_stage, state)
86
+ result = runner.validate_stage("BENCHMARK", task_name)
87
+ if result.message.endswith("(condition met)"):
88
+ return get_next_stage(next_stage, state)
89
+
90
+ return next_stage
91
+
92
+
93
+ def execute_stage(state: WorkflowState, tui_app: WorkflowTUIApp = None) -> tuple[bool, str]:
94
+ """Execute the current stage. Returns (success, message).
95
+
96
+ If tui_app is provided, uses the persistent TUI instead of creating a new one.
97
+ """
98
+ stage = state.stage
99
+ task_name = state.task_name
100
+ config = get_config()
101
+
102
+ if stage == Stage.COMPLETE:
103
+ return True, "Workflow complete"
104
+
105
+ # Design approval gate (only in legacy mode without TUI)
106
+ if stage == Stage.DEV and tui_app is None:
107
+ from galangal.commands.approve import prompt_design_approval
108
+ design_skipped = artifact_exists(
109
+ "DESIGN_SKIP.md", task_name
110
+ ) or should_skip_for_task_type(Stage.DESIGN, state.task_type)
111
+
112
+ if design_skipped:
113
+ pass
114
+ elif not artifact_exists("DESIGN_REVIEW.md", task_name):
115
+ result = prompt_design_approval(task_name, state)
116
+ if result == "quit":
117
+ return False, "PAUSED: User requested pause"
118
+ elif result == "rejected":
119
+ return True, "Design rejected - restarting from DESIGN"
120
+
121
+ # Check for clarification
122
+ if artifact_exists("QUESTIONS.md", task_name) and not artifact_exists(
123
+ "ANSWERS.md", task_name
124
+ ):
125
+ state.clarification_required = True
126
+ save_state(state)
127
+ return False, "Clarification required. Please provide ANSWERS.md."
128
+
129
+ # PREFLIGHT runs validation directly
130
+ if stage == Stage.PREFLIGHT:
131
+ if tui_app:
132
+ tui_app.add_activity("Running preflight checks...", "⚙")
133
+ else:
134
+ console.print("[dim]Running preflight checks...[/dim]")
135
+
136
+ runner = ValidationRunner()
137
+ result = runner.validate_stage("PREFLIGHT", task_name)
138
+
139
+ if result.success:
140
+ if tui_app:
141
+ tui_app.show_message(f"Preflight: {result.message}", "success")
142
+ else:
143
+ console.print(f"[green]✓ Preflight: {result.message}[/green]")
144
+ return True, result.message
145
+ else:
146
+ # Include detailed output in the failure message for display
147
+ detailed_message = result.message
148
+ if result.output:
149
+ detailed_message = f"{result.message}\n\n{result.output}"
150
+ # Return special marker so workflow knows this is preflight failure
151
+ return False, f"PREFLIGHT_FAILED:{detailed_message}"
152
+
153
+ # Get current branch for UI
154
+ branch = get_current_branch()
155
+
156
+ # Build prompt
157
+ builder = PromptBuilder()
158
+ prompt = builder.build_full_prompt(stage, state)
159
+
160
+ # Add retry context
161
+ if state.attempt > 1 and state.last_failure:
162
+ retry_context = f"""
163
+ ## ⚠️ RETRY ATTEMPT {state.attempt}
164
+
165
+ The previous attempt failed with the following error:
166
+
167
+ ```
168
+ {state.last_failure[:1000]}
169
+ ```
170
+
171
+ Please fix the issue above before proceeding. Do not repeat the same mistake.
172
+ """
173
+ prompt = f"{prompt}\n\n{retry_context}"
174
+
175
+ # Log the prompt
176
+ logs_dir = get_task_dir(task_name) / "logs"
177
+ logs_dir.mkdir(parents=True, exist_ok=True)
178
+ log_file = logs_dir / f"{stage.value.lower()}_{state.attempt}.log"
179
+ with open(log_file, "w") as f:
180
+ f.write(f"=== Prompt ===\n{prompt}\n\n")
181
+
182
+ # Run stage - either with persistent TUI or standalone
183
+ if tui_app:
184
+ # Use the persistent TUI
185
+ backend = ClaudeBackend()
186
+ ui = TUIAdapter(tui_app)
187
+ success, output = backend.invoke(
188
+ prompt=prompt,
189
+ timeout=14400,
190
+ max_turns=200,
191
+ ui=ui,
192
+ )
193
+ else:
194
+ # Create new TUI for this stage
195
+ success, output = run_stage_with_tui(
196
+ task_name=task_name,
197
+ stage=stage.value,
198
+ branch=branch,
199
+ attempt=state.attempt,
200
+ prompt=prompt,
201
+ )
202
+
203
+ # Log the output
204
+ with open(log_file, "a") as f:
205
+ f.write(f"=== Output ===\n{output}\n")
206
+
207
+ if not success:
208
+ return False, output
209
+
210
+ # Validate stage
211
+ if tui_app:
212
+ tui_app.add_activity("Validating stage outputs...", "⚙")
213
+ else:
214
+ console.print("[dim]Validating stage outputs...[/dim]")
215
+
216
+ runner = ValidationRunner()
217
+ result = runner.validate_stage(stage.value, task_name)
218
+
219
+ with open(log_file, "a") as f:
220
+ f.write(f"\n=== Validation ===\n{result.message}\n")
221
+
222
+ if result.success:
223
+ if tui_app:
224
+ tui_app.show_message(result.message, "success")
225
+ else:
226
+ console.print(f"[green]✓ {result.message}[/green]")
227
+ else:
228
+ if tui_app:
229
+ tui_app.show_message(result.message, "error")
230
+ else:
231
+ console.print(f"[red]✗ {result.message}[/red]")
232
+
233
+ # Include rollback target in message if validation failed
234
+ if not result.success and result.rollback_to:
235
+ return False, f"ROLLBACK:{result.rollback_to}:{result.message}"
236
+
237
+ return result.success, result.message
238
+
239
+
240
+ def archive_rollback_if_exists(task_name: str) -> None:
241
+ """Archive ROLLBACK.md after DEV stage succeeds."""
242
+ if not artifact_exists("ROLLBACK.md", task_name):
243
+ return
244
+
245
+ rollback_content = read_artifact("ROLLBACK.md", task_name)
246
+ resolved_path = artifact_path("ROLLBACK_RESOLVED.md", task_name)
247
+
248
+ resolution_note = f"\n\n## Resolved: {datetime.now(timezone.utc).isoformat()}\n\nIssues fixed by DEV stage.\n"
249
+
250
+ if resolved_path.exists():
251
+ existing = resolved_path.read_text()
252
+ resolved_path.write_text(
253
+ existing + "\n---\n" + rollback_content + resolution_note
254
+ )
255
+ else:
256
+ resolved_path.write_text(rollback_content + resolution_note)
257
+
258
+ rollback_path = artifact_path("ROLLBACK.md", task_name)
259
+ rollback_path.unlink()
260
+
261
+ console.print(" [dim]📋 Archived ROLLBACK.md → ROLLBACK_RESOLVED.md[/dim]")
262
+
263
+
264
+ def handle_rollback(state: WorkflowState, message: str) -> bool:
265
+ """Handle a rollback signal from a stage validator."""
266
+ runner = ValidationRunner()
267
+
268
+ # Check for rollback_to in validation result
269
+ if not message.startswith("ROLLBACK:") and "rollback" not in message.lower():
270
+ return False
271
+
272
+ # Parse rollback target
273
+ target_stage = Stage.DEV # Default rollback target
274
+
275
+ if message.startswith("ROLLBACK:"):
276
+ parts = message.split(":", 2)
277
+ if len(parts) >= 2:
278
+ try:
279
+ target_stage = Stage.from_str(parts[1])
280
+ except ValueError:
281
+ pass
282
+
283
+ task_name = state.task_name
284
+ from_stage = state.stage
285
+
286
+ reason = message.split(":", 2)[-1] if ":" in message else message
287
+
288
+ rollback_entry = f"""
289
+ ## Rollback from {from_stage.value}
290
+
291
+ **Date:** {datetime.now(timezone.utc).isoformat()}
292
+ **From Stage:** {from_stage.value}
293
+ **Target Stage:** {target_stage.value}
294
+ **Reason:** {reason}
295
+
296
+ ### Required Actions
297
+ {reason}
298
+
299
+ ---
300
+ """
301
+
302
+ existing = read_artifact("ROLLBACK.md", task_name)
303
+ if existing:
304
+ new_content = existing + rollback_entry
305
+ else:
306
+ new_content = f"# Rollback Log\n\nThis file tracks issues that required rolling back to earlier stages.\n{rollback_entry}"
307
+
308
+ write_artifact("ROLLBACK.md", new_content, task_name)
309
+
310
+ state.stage = target_stage
311
+ state.attempt = 1
312
+ state.last_failure = f"Rollback from {from_stage.value}: {reason}"
313
+ save_state(state)
314
+
315
+ console.print(
316
+ f"\n[yellow]⚠️ ROLLBACK: {from_stage.value} → {target_stage.value}[/yellow]"
317
+ )
318
+ console.print(f" Reason: {reason}")
319
+ console.print(" See ROLLBACK.md for details\n")
320
+
321
+ return True
322
+
323
+
324
+ def run_workflow(state: WorkflowState) -> None:
325
+ """Run the workflow from current state to completion or failure."""
326
+ import os
327
+ import threading
328
+
329
+ # Try persistent TUI first (unless disabled)
330
+ if not os.environ.get("GALANGAL_NO_TUI"):
331
+ try:
332
+ result = _run_workflow_with_tui(state)
333
+ if result != "use_legacy":
334
+ # TUI handled the workflow
335
+ return
336
+ except Exception as e:
337
+ console.print(f"[yellow]TUI error: {e}. Using legacy mode.[/yellow]")
338
+
339
+ # Legacy mode (no persistent TUI)
340
+ _run_workflow_legacy(state)
341
+
342
+
343
+ def _run_workflow_with_tui(state: WorkflowState) -> str:
344
+ """Run workflow with persistent TUI. Returns result or 'use_legacy' to fall back."""
345
+ import threading
346
+
347
+ config = get_config()
348
+ max_retries = config.stages.max_retries
349
+
350
+ # Compute hidden stages based on task type and config
351
+ hidden_stages = frozenset(
352
+ get_hidden_stages_for_task_type(state.task_type, config.stages.skip)
353
+ )
354
+
355
+ app = WorkflowTUIApp(
356
+ state.task_name,
357
+ state.stage.value,
358
+ hidden_stages=hidden_stages,
359
+ )
360
+
361
+ # Shared state for thread communication
362
+ workflow_done = threading.Event()
363
+
364
+ def workflow_thread():
365
+ """Run the workflow loop in a background thread."""
366
+ try:
367
+ while state.stage != Stage.COMPLETE and not app._paused:
368
+ app.update_stage(state.stage.value, state.attempt)
369
+ app.set_status("running", f"executing {state.stage.value}")
370
+
371
+ # Execute stage with the TUI app
372
+ success, message = execute_stage(state, tui_app=app)
373
+
374
+ if app._paused:
375
+ app._workflow_result = "paused"
376
+ break
377
+
378
+ if not success:
379
+ app.show_stage_complete(state.stage.value, False)
380
+
381
+ # Handle preflight failures specially - don't auto-retry
382
+ if message.startswith("PREFLIGHT_FAILED:"):
383
+ detailed_error = message[len("PREFLIGHT_FAILED:") :]
384
+ app.show_message("Preflight checks failed", "error")
385
+ # Show the detailed report in the activity log
386
+ for line in detailed_error.strip().split("\n"):
387
+ if line.strip():
388
+ app.add_activity(line)
389
+
390
+ # Prompt user to retry after fixing
391
+ retry_event = threading.Event()
392
+ retry_result = {"value": None}
393
+
394
+ def handle_preflight_retry(choice):
395
+ retry_result["value"] = choice
396
+ retry_event.set()
397
+
398
+ app.show_prompt(
399
+ PromptType.PREFLIGHT_RETRY,
400
+ "Fix environment issues and retry?",
401
+ handle_preflight_retry,
402
+ )
403
+
404
+ retry_event.wait()
405
+ if retry_result["value"] == "retry":
406
+ app.show_message("Retrying preflight checks...", "info")
407
+ continue # Retry without incrementing attempt
408
+ else:
409
+ save_state(state)
410
+ app._workflow_result = "paused"
411
+ break
412
+
413
+ if state.awaiting_approval or state.clarification_required:
414
+ app.show_message(message, "warning")
415
+ save_state(state)
416
+ app._workflow_result = "paused"
417
+ break
418
+
419
+ if handle_rollback(state, message):
420
+ app.show_message(f"Rolling back: {message[:80]}", "warning")
421
+ continue
422
+
423
+ state.attempt += 1
424
+ state.last_failure = message
425
+
426
+ if state.attempt > max_retries:
427
+ app.show_message(f"Max retries ({max_retries}) exceeded for {state.stage.value}", "error")
428
+ # Show the failure details in the activity log
429
+ app.add_activity("")
430
+ app.add_activity("[bold red]Last failure:[/bold red]")
431
+ for line in message[:2000].split("\n"):
432
+ if line.strip():
433
+ app.add_activity(f" {line}")
434
+
435
+ # Prompt user for what to do
436
+ failure_event = threading.Event()
437
+ failure_result = {"value": None, "feedback": None}
438
+
439
+ def handle_failure_choice(choice):
440
+ failure_result["value"] = choice
441
+ if choice == "fix_in_dev":
442
+ # Need to get feedback first
443
+ def handle_feedback(feedback):
444
+ failure_result["feedback"] = feedback
445
+ failure_event.set()
446
+
447
+ app.show_text_input(
448
+ "Describe what needs to be fixed:",
449
+ handle_feedback,
450
+ )
451
+ else:
452
+ failure_event.set()
453
+
454
+ app.show_prompt(
455
+ PromptType.STAGE_FAILURE,
456
+ f"Stage {state.stage.value} failed after {max_retries} attempts. What would you like to do?",
457
+ handle_failure_choice,
458
+ )
459
+
460
+ failure_event.wait()
461
+
462
+ if failure_result["value"] == "retry":
463
+ state.attempt = 1 # Reset attempts
464
+ app.show_message("Retrying stage...", "info")
465
+ save_state(state)
466
+ continue
467
+ elif failure_result["value"] == "fix_in_dev":
468
+ feedback = failure_result["feedback"] or "Fix the failing stage"
469
+ # Roll back to DEV with feedback
470
+ failing_stage = state.stage.value
471
+ state.stage = Stage.DEV
472
+ state.attempt = 1
473
+ state.last_failure = f"Feedback from {failing_stage} failure: {feedback}\n\nOriginal error:\n{message[:1500]}"
474
+ app.show_message(f"Rolling back to DEV with feedback", "warning")
475
+ save_state(state)
476
+ continue
477
+ else:
478
+ save_state(state)
479
+ app._workflow_result = "paused"
480
+ break
481
+
482
+ app.show_message(f"Retrying (attempt {state.attempt}/{max_retries})...", "warning")
483
+ save_state(state)
484
+ continue
485
+
486
+ app.show_stage_complete(state.stage.value, True)
487
+
488
+ # Plan approval gate - handle in TUI with two-step flow
489
+ if state.stage == Stage.PM and not artifact_exists("APPROVAL.md", state.task_name):
490
+ approval_event = threading.Event()
491
+ approval_result = {"value": None, "approver": None, "reason": None}
492
+
493
+ # Get default approver name from config
494
+ default_approver = config.project.approver_name or ""
495
+
496
+ def handle_approval(choice):
497
+ if choice == "yes":
498
+ approval_result["value"] = "pending_name"
499
+ # Don't set event yet - wait for name input
500
+ elif choice == "no":
501
+ approval_result["value"] = "pending_reason"
502
+ # Don't set event yet - wait for rejection reason
503
+ else:
504
+ approval_result["value"] = "quit"
505
+ approval_event.set()
506
+
507
+ def handle_approver_name(name):
508
+ if name:
509
+ approval_result["value"] = "approved"
510
+ approval_result["approver"] = name
511
+ # Write approval artifact with approver name
512
+ from datetime import datetime, timezone
513
+ approval_content = f"""# Plan Approval
514
+
515
+ - **Status:** Approved
516
+ - **Approved By:** {name}
517
+ - **Date:** {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
518
+ """
519
+ write_artifact("APPROVAL.md", approval_content, state.task_name)
520
+ app.show_message(f"Plan approved by {name}", "success")
521
+ else:
522
+ # Cancelled - go back to approval prompt
523
+ approval_result["value"] = None
524
+ app.show_prompt(PromptType.PLAN_APPROVAL, "Approve plan to continue?", handle_approval)
525
+ return # Don't set event - wait for new choice
526
+ approval_event.set()
527
+
528
+ def handle_rejection_reason(reason):
529
+ if reason:
530
+ approval_result["value"] = "rejected"
531
+ approval_result["reason"] = reason
532
+ state.stage = Stage.PM
533
+ state.attempt = 1
534
+ state.last_failure = f"Plan rejected: {reason}"
535
+ save_state(state)
536
+ app.show_message(f"Plan rejected: {reason}", "warning")
537
+ else:
538
+ # Cancelled - go back to approval prompt
539
+ approval_result["value"] = None
540
+ app.show_prompt(PromptType.PLAN_APPROVAL, "Approve plan to continue?", handle_approval)
541
+ return # Don't set event - wait for new choice
542
+ approval_event.set()
543
+
544
+ app.show_prompt(PromptType.PLAN_APPROVAL, "Approve plan to continue?", handle_approval)
545
+
546
+ # Wait for choice, then potentially for name or reason
547
+ while not approval_event.is_set():
548
+ approval_event.wait(timeout=0.1)
549
+ if approval_result["value"] == "pending_name":
550
+ approval_result["value"] = None # Reset
551
+ app.show_text_input("Enter approver name:", default_approver, handle_approver_name)
552
+ elif approval_result["value"] == "pending_reason":
553
+ approval_result["value"] = None # Reset
554
+ app.show_text_input("Enter rejection reason:", "Needs revision", handle_rejection_reason)
555
+
556
+ if approval_result["value"] == "quit":
557
+ app._workflow_result = "paused"
558
+ break
559
+ elif approval_result["value"] == "rejected":
560
+ app.show_message("Restarting PM stage with feedback...", "info")
561
+ continue
562
+
563
+ if state.stage == Stage.DEV:
564
+ archive_rollback_if_exists(state.task_name)
565
+
566
+ # Get next stage
567
+ next_stage = get_next_stage(state.stage, state)
568
+ if next_stage:
569
+ expected_next_idx = STAGE_ORDER.index(state.stage) + 1
570
+ actual_next_idx = STAGE_ORDER.index(next_stage)
571
+ if actual_next_idx > expected_next_idx:
572
+ skipped = STAGE_ORDER[expected_next_idx:actual_next_idx]
573
+ for s in skipped:
574
+ app.show_message(f"Skipped {s.value} (condition not met)", "info")
575
+
576
+ state.stage = next_stage
577
+ state.attempt = 1
578
+ state.last_failure = None
579
+ state.awaiting_approval = False
580
+ state.clarification_required = False
581
+ save_state(state)
582
+ else:
583
+ state.stage = Stage.COMPLETE
584
+ save_state(state)
585
+
586
+ # Workflow complete
587
+ if state.stage == Stage.COMPLETE:
588
+ app.show_workflow_complete()
589
+ app.update_stage("COMPLETE")
590
+ app.set_status("complete", "workflow finished")
591
+
592
+ completion_event = threading.Event()
593
+ completion_result = {"value": None}
594
+
595
+ def handle_completion(choice):
596
+ completion_result["value"] = choice
597
+ completion_event.set()
598
+
599
+ app.show_prompt(PromptType.COMPLETION, "Workflow complete!", handle_completion)
600
+ completion_event.wait()
601
+
602
+ if completion_result["value"] == "yes":
603
+ app._workflow_result = "create_pr"
604
+ elif completion_result["value"] == "no":
605
+ state.stage = Stage.DEV
606
+ state.attempt = 1
607
+ save_state(state)
608
+ app._workflow_result = "back_to_dev"
609
+ else:
610
+ app._workflow_result = "paused"
611
+
612
+ except Exception as e:
613
+ app.show_message(f"Error: {e}", "error")
614
+ app._workflow_result = "error"
615
+ finally:
616
+ workflow_done.set()
617
+ app.call_from_thread(app.set_timer, 0.5, app.exit)
618
+
619
+ # Start workflow in background thread
620
+ thread = threading.Thread(target=workflow_thread, daemon=True)
621
+
622
+ def start_thread():
623
+ thread.start()
624
+
625
+ app.call_later(start_thread)
626
+ app.run()
627
+
628
+ # Handle result
629
+ result = app._workflow_result or "paused"
630
+
631
+ if result == "create_pr":
632
+ from galangal.commands.complete import finalize_task
633
+ finalize_task(state.task_name, state, force=False)
634
+ elif result == "back_to_dev":
635
+ # Restart workflow from DEV
636
+ return _run_workflow_with_tui(state)
637
+ elif result == "paused":
638
+ _handle_pause(state)
639
+
640
+ return result
641
+
642
+
643
+ def _run_workflow_legacy(state: WorkflowState) -> None:
644
+ """Run workflow without persistent TUI (legacy mode)."""
645
+ from galangal.commands.approve import prompt_plan_approval
646
+ from galangal.commands.complete import finalize_task
647
+
648
+ global _current_state
649
+ config = get_config()
650
+ max_retries = config.stages.max_retries
651
+
652
+ _current_state = state
653
+ original_handler = signal.signal(signal.SIGINT, _signal_handler)
654
+
655
+ try:
656
+ while True:
657
+ # Run stages until COMPLETE
658
+ while state.stage != Stage.COMPLETE:
659
+ if get_pause_requested():
660
+ _handle_pause(state)
661
+ return
662
+
663
+ success, message = execute_stage(state)
664
+
665
+ if get_pause_requested() or message == "PAUSED: User requested pause":
666
+ _handle_pause(state)
667
+ return
668
+
669
+ if not success:
670
+ # Handle preflight failures specially - don't auto-retry
671
+ if message.startswith("PREFLIGHT_FAILED:"):
672
+ detailed_error = message[len("PREFLIGHT_FAILED:") :]
673
+ console.print("\n[red]✗ Preflight checks failed[/red]\n")
674
+ console.print(detailed_error)
675
+ console.print()
676
+
677
+ # Prompt user to retry after fixing
678
+ while True:
679
+ console.print("[yellow]Fix environment issues and retry?[/yellow]")
680
+ console.print(" [bold]1[/bold] Retry")
681
+ console.print(" [bold]2[/bold] Quit")
682
+ choice = console.input("\n[bold]Choice:[/bold] ").strip()
683
+ if choice == "1":
684
+ console.print("\n[dim]Retrying preflight checks...[/dim]")
685
+ break # Break out of while loop, continue outer loop
686
+ elif choice == "2":
687
+ save_state(state)
688
+ return
689
+ else:
690
+ console.print("[red]Invalid choice. Please enter 1 or 2.[/red]\n")
691
+ continue # Retry without incrementing attempt
692
+
693
+ if state.awaiting_approval or state.clarification_required:
694
+ console.print(f"\n{message}")
695
+ save_state(state)
696
+ return
697
+
698
+ if handle_rollback(state, message):
699
+ continue
700
+
701
+ state.attempt += 1
702
+ state.last_failure = message
703
+
704
+ if state.attempt > max_retries:
705
+ console.print(
706
+ f"\n[red]Max retries ({max_retries}) exceeded for stage {state.stage.value}[/red]"
707
+ )
708
+ console.print("\n[bold]Last failure:[/bold]")
709
+ console.print(message[:2000])
710
+ console.print()
711
+
712
+ # Prompt user for what to do
713
+ while True:
714
+ console.print(f"[yellow]Stage {state.stage.value} failed after {max_retries} attempts. What would you like to do?[/yellow]")
715
+ console.print(" [bold]1[/bold] Retry (reset attempts)")
716
+ console.print(" [bold]2[/bold] Fix in DEV (add feedback and roll back)")
717
+ console.print(" [bold]3[/bold] Quit")
718
+ choice = console.input("\n[bold]Choice:[/bold] ").strip()
719
+
720
+ if choice == "1":
721
+ state.attempt = 1 # Reset attempts
722
+ console.print("\n[dim]Retrying stage...[/dim]")
723
+ save_state(state)
724
+ break
725
+ elif choice == "2":
726
+ feedback = console.input("\n[bold]Describe what needs to be fixed:[/bold] ").strip()
727
+ if not feedback:
728
+ feedback = "Fix the failing stage"
729
+ failing_stage = state.stage.value
730
+ state.stage = Stage.DEV
731
+ state.attempt = 1
732
+ state.last_failure = f"Feedback from {failing_stage} failure: {feedback}\n\nOriginal error:\n{message[:1500]}"
733
+ console.print("\n[yellow]Rolling back to DEV with feedback[/yellow]")
734
+ save_state(state)
735
+ break
736
+ elif choice == "3":
737
+ save_state(state)
738
+ return
739
+ else:
740
+ console.print("[red]Invalid choice. Please enter 1, 2, or 3.[/red]\n")
741
+ continue
742
+
743
+ console.print(
744
+ f"\n[yellow]Stage failed, retrying (attempt {state.attempt}/{max_retries})...[/yellow]"
745
+ )
746
+ console.print(f"Failure: {message[:500]}...")
747
+ save_state(state)
748
+ continue
749
+
750
+ console.print(
751
+ f"\n[green]Stage {state.stage.value} completed successfully[/green]"
752
+ )
753
+
754
+ # Plan approval gate
755
+ if state.stage == Stage.PM and not artifact_exists("APPROVAL.md", state.task_name):
756
+ result = prompt_plan_approval(state.task_name, state)
757
+ if result == "quit":
758
+ _handle_pause(state)
759
+ return
760
+ elif result == "rejected":
761
+ continue
762
+
763
+ if state.stage == Stage.DEV:
764
+ archive_rollback_if_exists(state.task_name)
765
+
766
+ next_stage = get_next_stage(state.stage, state)
767
+ if next_stage:
768
+ expected_next_idx = STAGE_ORDER.index(state.stage) + 1
769
+ actual_next_idx = STAGE_ORDER.index(next_stage)
770
+ if actual_next_idx > expected_next_idx:
771
+ skipped = STAGE_ORDER[expected_next_idx:actual_next_idx]
772
+ for s in skipped:
773
+ console.print(
774
+ f" [dim]⏭️ Skipped {s.value} (condition not met)[/dim]"
775
+ )
776
+
777
+ state.stage = next_stage
778
+ state.attempt = 1
779
+ state.last_failure = None
780
+ state.awaiting_approval = False
781
+ state.clarification_required = False
782
+ save_state(state)
783
+ else:
784
+ state.stage = Stage.COMPLETE
785
+ save_state(state)
786
+
787
+ # Workflow complete
788
+ console.print("\n" + "=" * 60)
789
+ console.print("[bold green]WORKFLOW COMPLETE[/bold green]")
790
+ console.print("=" * 60)
791
+
792
+ from rich.prompt import Prompt
793
+
794
+ console.print("\n[bold]Options:[/bold]")
795
+ console.print(" [green]y[/green] - Create PR and finalize")
796
+ console.print(" [yellow]n[/yellow] - Review and make changes (go back to DEV)")
797
+ console.print(" [yellow]q[/yellow] - Quit (finalize later with 'galangal complete')")
798
+
799
+ while True:
800
+ choice = Prompt.ask("Your choice", default="y").strip().lower()
801
+
802
+ if choice in ["y", "yes"]:
803
+ finalize_task(state.task_name, state, force=False)
804
+ return
805
+ elif choice in ["n", "no"]:
806
+ state.stage = Stage.DEV
807
+ state.attempt = 1
808
+ save_state(state)
809
+ console.print("\n[dim]Going back to DEV stage...[/dim]")
810
+ break
811
+ elif choice in ["q", "quit"]:
812
+ _handle_pause(state)
813
+ return
814
+ else:
815
+ console.print("[red]Invalid choice. Enter y/n/q[/red]")
816
+
817
+ finally:
818
+ signal.signal(signal.SIGINT, original_handler)
819
+ _current_state = None
820
+
821
+
822
+ def _handle_pause(state: WorkflowState) -> None:
823
+ """Handle a pause request."""
824
+ set_pause_requested(False)
825
+ save_state(state)
826
+
827
+ console.print("\n" + "=" * 60)
828
+ console.print("[yellow]⏸️ TASK PAUSED[/yellow]")
829
+ console.print("=" * 60)
830
+ console.print(f"\nTask: {state.task_name}")
831
+ console.print(f"Stage: {state.stage.value} (attempt {state.attempt})")
832
+ console.print("\nYour progress has been saved. You can safely shut down now.")
833
+ console.print("\nTo resume later, run:")
834
+ console.print(" [cyan]galangal resume[/cyan]")
835
+ console.print("=" * 60)