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,1322 @@
1
+ """
2
+ TUI-based workflow runner using persistent Textual app with async/await.
3
+
4
+ This module uses Textual's async capabilities for cleaner coordination
5
+ between UI events and workflow logic, eliminating manual threading.Event
6
+ coordination in favor of asyncio.Future-based prompts.
7
+
8
+ The workflow logic is delegated to WorkflowEngine, which handles all state
9
+ transitions. This module is responsible for:
10
+ - UI orchestration (displaying events, collecting input)
11
+ - Translating engine events to visual updates
12
+ - Collecting user input and passing actions to the engine
13
+ """
14
+
15
+ import asyncio
16
+ from typing import Any
17
+
18
+ from rich.console import Console
19
+
20
+ from galangal.config.loader import get_config
21
+ from galangal.config.schema import GalangalConfig
22
+ from galangal.core.artifacts import parse_stage_plan, write_artifact
23
+ from galangal.core.state import (
24
+ STAGE_ORDER,
25
+ TASK_TYPE_SKIP_STAGES,
26
+ Stage,
27
+ TaskType,
28
+ WorkflowState,
29
+ get_conditional_stages,
30
+ get_hidden_stages_for_task_type,
31
+ get_task_dir,
32
+ save_state,
33
+ )
34
+ from galangal.core.workflow.engine import (
35
+ ActionType,
36
+ EventType,
37
+ WorkflowEngine,
38
+ WorkflowEvent,
39
+ action,
40
+ )
41
+ from galangal.core.workflow.pause import _handle_pause
42
+ from galangal.prompts.builder import PromptBuilder
43
+ from galangal.ui.tui import PromptType, WorkflowTUIApp
44
+ from galangal.validation.runner import ValidationRunner
45
+
46
+ console = Console()
47
+
48
+
49
+ def _run_workflow_with_tui(state: WorkflowState) -> str:
50
+ """
51
+ Execute the workflow loop with a persistent Textual TUI.
52
+
53
+ This is the main entry point for running workflows interactively. It creates
54
+ a WorkflowTUIApp and runs the stage pipeline using async/await for clean
55
+ coordination between UI and workflow logic.
56
+
57
+ The workflow logic is delegated to WorkflowEngine. This function handles:
58
+ - UI orchestration
59
+ - Translating engine events to visual updates
60
+ - Collecting user input for interactive prompts
61
+
62
+ Threading Model (Async):
63
+ - Main thread: Runs the Textual TUI event loop
64
+ - Async worker: Executes workflow logic using Textual's run_worker()
65
+ - Blocking operations (execute_stage) run in thread executor
66
+
67
+ Args:
68
+ state: Current workflow state containing task info, current stage,
69
+ attempt count, and failure information.
70
+
71
+ Returns:
72
+ Result string indicating outcome:
73
+ - "done": Workflow completed successfully and user chose to exit
74
+ - "new_task": User chose to create a new task after completion
75
+ - "paused": Workflow was paused (Ctrl+C or user quit)
76
+ - "back_to_dev": User requested changes at completion, rolling back
77
+ - "error": An exception occurred during execution
78
+ """
79
+ config = get_config()
80
+
81
+ # Compute hidden stages based on task type and config
82
+ hidden_stages = frozenset(get_hidden_stages_for_task_type(state.task_type, config.stages.skip))
83
+
84
+ app = WorkflowTUIApp(
85
+ state.task_name,
86
+ state.stage.value,
87
+ hidden_stages=hidden_stages,
88
+ stage_durations=state.stage_durations,
89
+ )
90
+
91
+ # Create workflow engine
92
+ engine = WorkflowEngine(state, config)
93
+
94
+ async def workflow_loop() -> None:
95
+ """Async workflow loop running within Textual's event loop."""
96
+ try:
97
+ while not engine.is_complete and not app._paused:
98
+ # Check GitHub issue status
99
+ github_event = await asyncio.to_thread(engine.check_github_issue)
100
+ if github_event:
101
+ app.show_message(github_event.message, "warning")
102
+ app.add_activity(
103
+ f"Issue #{state.github_issue} closed externally - pausing", "⚠"
104
+ )
105
+ app._workflow_result = "paused"
106
+ break
107
+
108
+ app.update_stage(engine.current_stage.value, state.attempt)
109
+ app.set_status("running", f"executing {engine.current_stage.value}")
110
+
111
+ # Start stage timer
112
+ engine.start_stage_timer()
113
+
114
+ # Run PM discovery Q&A before PM stage execution
115
+ if engine.current_stage == Stage.PM and not state.qa_complete:
116
+ skip_discovery = getattr(state, "_skip_discovery", False)
117
+ discovery_ok = await _run_pm_discovery(app, state, skip_discovery)
118
+ if not discovery_ok:
119
+ app._workflow_result = "paused"
120
+ break
121
+ app.set_status("running", f"executing {engine.current_stage.value}")
122
+
123
+ # Execute stage in thread executor
124
+ workflow_event = await asyncio.to_thread(
125
+ engine.execute_current_stage,
126
+ app,
127
+ lambda: app._paused,
128
+ )
129
+
130
+ # Handle user interrupt requests (Ctrl+I, Ctrl+N, Ctrl+B, Ctrl+E)
131
+ interrupt_result = await _handle_user_interrupts(app, engine)
132
+ if interrupt_result == "continue":
133
+ continue
134
+ elif interrupt_result == "paused":
135
+ app._workflow_result = "paused"
136
+ break
137
+
138
+ if app._paused:
139
+ app._workflow_result = "paused"
140
+ break
141
+
142
+ # Handle the workflow event
143
+ result = await _handle_workflow_event(app, engine, workflow_event, config)
144
+ if result == "break":
145
+ break
146
+ elif result == "continue":
147
+ continue
148
+
149
+ # Workflow complete
150
+ if engine.is_complete:
151
+ await _handle_workflow_complete(app, state)
152
+
153
+ except Exception as e:
154
+ from galangal.core.utils import debug_exception
155
+
156
+ debug_exception("Workflow execution failed", e)
157
+ app.show_error("Workflow error", str(e))
158
+ app._workflow_result = "error"
159
+ await app.ask_yes_no_async(
160
+ "An error occurred. Press Enter to exit and see details in the debug log."
161
+ )
162
+ app.set_timer(0.5, app.exit)
163
+ return
164
+ finally:
165
+ if app._workflow_result != "error":
166
+ app.set_timer(0.5, app.exit)
167
+
168
+ # Start workflow as async worker
169
+ app.call_later(lambda: app.run_worker(workflow_loop(), exclusive=True))
170
+ app.run()
171
+
172
+ # Handle result
173
+ result = app._workflow_result or "paused"
174
+
175
+ if result == "new_task":
176
+ return _start_new_task_tui()
177
+ elif result == "done":
178
+ console.print("\n[green]✓ All done![/green]")
179
+ return result
180
+ elif result == "back_to_dev":
181
+ return _run_workflow_with_tui(state)
182
+ elif result == "paused":
183
+ _handle_pause(state)
184
+
185
+ return result
186
+
187
+
188
+ # =============================================================================
189
+ # Event Handlers - translate engine events to UI updates
190
+ # =============================================================================
191
+
192
+
193
+ async def _handle_workflow_event(
194
+ app: WorkflowTUIApp,
195
+ engine: WorkflowEngine,
196
+ event: WorkflowEvent,
197
+ config: GalangalConfig,
198
+ ) -> str:
199
+ """
200
+ Handle a workflow event from the engine.
201
+
202
+ Returns:
203
+ "continue" to continue the loop
204
+ "break" to exit the loop
205
+ "advance" to advance to next stage (handled by caller)
206
+ """
207
+ state = engine.state
208
+
209
+ if event.type == EventType.WORKFLOW_PAUSED:
210
+ app._workflow_result = "paused"
211
+ return "break"
212
+
213
+ if event.type == EventType.STAGE_COMPLETED:
214
+ app.clear_error()
215
+ duration = state.record_stage_duration()
216
+ app.show_stage_complete(state.stage.value, True, duration)
217
+ if state.stage_durations:
218
+ app.update_stage_durations(state.stage_durations)
219
+
220
+ # Advance to next stage via engine
221
+ advance_event = engine.handle_action(action(ActionType.CONTINUE), tui_app=app)
222
+ return await _handle_advance_event(app, engine, advance_event, config)
223
+
224
+ if event.type == EventType.APPROVAL_REQUIRED:
225
+ should_continue = await _handle_stage_approval(
226
+ app, state, config, event.data.get("artifact_name", "APPROVAL.md")
227
+ )
228
+ if not should_continue:
229
+ if app._workflow_result == "paused":
230
+ return "break"
231
+ return "continue" # Rejected - loop back to stage
232
+
233
+ # After approval, advance
234
+ advance_event = engine.handle_action(action(ActionType.CONTINUE), tui_app=app)
235
+ return await _handle_advance_event(app, engine, advance_event, config)
236
+
237
+ if event.type == EventType.PREFLIGHT_FAILED:
238
+ app.show_stage_complete(state.stage.value, False)
239
+ modal_message = _build_preflight_error_message(event.message, event.data.get("details", ""))
240
+ choice = await app.prompt_async(PromptType.PREFLIGHT_RETRY, modal_message)
241
+
242
+ if choice == "retry":
243
+ app.show_message("Retrying preflight checks...", "info")
244
+ return "continue"
245
+ else:
246
+ save_state(state)
247
+ app._workflow_result = "paused"
248
+ return "break"
249
+
250
+ if event.type == EventType.CLARIFICATION_REQUIRED:
251
+ app.show_stage_complete(state.stage.value, False)
252
+ questions = event.data.get("questions", [])
253
+ if questions:
254
+ app.show_message(f"Stage has {len(questions)} clarifying question(s)", "warning")
255
+ answers = await app.question_answer_session_async(questions)
256
+ if answers:
257
+ engine.handle_clarification_answers(questions, answers)
258
+ app.show_message("Answers saved - resuming stage", "success")
259
+ return "continue"
260
+ else:
261
+ app.show_message("Answers cancelled - pausing workflow", "warning")
262
+ save_state(state)
263
+ app._workflow_result = "paused"
264
+ return "break"
265
+ else:
266
+ app.show_message("QUESTIONS.md exists but couldn't parse questions", "error")
267
+ save_state(state)
268
+ app._workflow_result = "paused"
269
+ return "break"
270
+
271
+ if event.type == EventType.USER_DECISION_REQUIRED:
272
+ app.show_stage_complete(state.stage.value, False)
273
+ artifact_preview = event.data.get("artifact_preview", "")
274
+ full_content = event.data.get("full_content", "")
275
+
276
+ while True:
277
+ choice = await app.prompt_async(
278
+ PromptType.USER_DECISION,
279
+ f"Decision file missing for {state.stage.value} stage.\n\n"
280
+ f"Report preview:\n{artifact_preview}\n\n"
281
+ "Please review and decide:",
282
+ )
283
+
284
+ if choice == "view":
285
+ app.add_activity("--- Full Report ---", "📄")
286
+ for line in (full_content or "No content").split("\n")[:50]:
287
+ app.add_activity(line, "")
288
+ app.add_activity("--- End Report ---", "📄")
289
+ continue
290
+
291
+ result_event = engine.handle_user_decision(choice, tui_app=app)
292
+
293
+ if result_event.type == EventType.WORKFLOW_PAUSED:
294
+ app._workflow_result = "paused"
295
+ return "break"
296
+
297
+ if result_event.type == EventType.WORKFLOW_COMPLETE:
298
+ app.show_workflow_complete()
299
+ app._workflow_result = "complete"
300
+ return "break"
301
+
302
+ if result_event.type == EventType.ROLLBACK_TRIGGERED:
303
+ app.show_message("Rolling back to DEV per user decision", "warning")
304
+ app.update_stage(state.stage.value, state.attempt)
305
+ return "continue"
306
+
307
+ if result_event.type == EventType.STAGE_STARTED:
308
+ # Advanced to next stage
309
+ app.update_stage(state.stage.value, state.attempt)
310
+ return "continue"
311
+
312
+ return "continue"
313
+
314
+ if event.type == EventType.ROLLBACK_TRIGGERED:
315
+ target = event.data.get("to_stage")
316
+ app.add_activity(f"Rolling back to {target.value if target else 'unknown'}", "⚠")
317
+ app.show_message(f"Rolling back: {event.message[:60]}", "warning")
318
+ app.update_stage(state.stage.value, state.attempt)
319
+ return "continue"
320
+
321
+ if event.type == EventType.ROLLBACK_BLOCKED:
322
+ app.show_stage_complete(state.stage.value, False)
323
+ block_reason = event.data.get("block_reason", "")
324
+ target = event.data.get("target_stage", "unknown")
325
+
326
+ app.add_activity(f"Rollback blocked: {block_reason}", "⚠")
327
+ app.show_error(f"Rollback blocked: {block_reason}", event.message[:500])
328
+
329
+ choice = await app.prompt_async(
330
+ PromptType.STAGE_FAILURE,
331
+ f"Rollback to {target} was blocked.\n\n"
332
+ f"Reason: {block_reason}\n\n"
333
+ f"Error: {event.message[:300]}\n\n"
334
+ "What would you like to do?",
335
+ )
336
+ app.clear_error()
337
+
338
+ if choice == "retry":
339
+ state.reset_attempts()
340
+ app.show_message("Retrying stage...", "info")
341
+ save_state(state)
342
+ return "continue"
343
+ elif choice == "fix_in_dev":
344
+ result_event = engine.handle_action(
345
+ action(ActionType.FIX_IN_DEV, error=event.message),
346
+ tui_app=app,
347
+ )
348
+ app.show_message("Rolling back to DEV (manual override)", "warning")
349
+ app.update_stage(state.stage.value, state.attempt)
350
+ return "continue"
351
+ else:
352
+ save_state(state)
353
+ app._workflow_result = "paused"
354
+ return "break"
355
+
356
+ if event.type == EventType.MAX_RETRIES_EXCEEDED:
357
+ app.show_stage_complete(state.stage.value, False)
358
+ max_retries = event.data.get("max_retries", config.stages.max_retries)
359
+ choice = await _handle_max_retries_exceeded(app, state, event.message, max_retries)
360
+
361
+ if choice == "retry":
362
+ state.reset_attempts()
363
+ app.show_message("Retrying stage...", "info")
364
+ save_state(state)
365
+ return "continue"
366
+ elif choice == "fix_in_dev":
367
+ # Already handled in _handle_max_retries_exceeded
368
+ return "continue"
369
+ else:
370
+ save_state(state)
371
+ app._workflow_result = "paused"
372
+ return "break"
373
+
374
+ if event.type == EventType.STAGE_FAILED:
375
+ app.show_stage_complete(state.stage.value, False)
376
+ app.show_message(
377
+ f"Retrying (attempt {state.attempt}/{engine.max_retries})...",
378
+ "warning",
379
+ )
380
+ save_state(state)
381
+ return "continue"
382
+
383
+ if event.type == EventType.WORKFLOW_COMPLETE:
384
+ return "break"
385
+
386
+ # Unknown event - continue
387
+ app.add_activity(f"Unknown event: {event.type.name}", "⚙")
388
+ return "continue"
389
+
390
+
391
+ async def _handle_advance_event(
392
+ app: WorkflowTUIApp,
393
+ engine: WorkflowEngine,
394
+ event: WorkflowEvent,
395
+ config: GalangalConfig,
396
+ ) -> str:
397
+ """Handle the event from advancing to next stage."""
398
+ state = engine.state
399
+
400
+ if event.type == EventType.WORKFLOW_COMPLETE:
401
+ return "break" # Will be handled in main loop
402
+
403
+ if event.type == EventType.STAGE_STARTED:
404
+ # Show skipped stages if any
405
+ skipped = event.data.get("skipped_stages", [])
406
+ for s in skipped:
407
+ app.show_message(f"Skipped {s.value} (condition not met)", "info")
408
+
409
+ app.update_stage(state.stage.value, state.attempt)
410
+
411
+ # After PM approval, show stage preview
412
+ if event.data.get("show_preview"):
413
+ preview_result = await _show_stage_preview(app, state, config)
414
+ if preview_result == "quit":
415
+ app._workflow_result = "paused"
416
+ return "break"
417
+
418
+ return "continue"
419
+
420
+ return "continue"
421
+
422
+
423
+ async def _handle_user_interrupts(app: WorkflowTUIApp, engine: WorkflowEngine) -> str:
424
+ """
425
+ Handle user interrupt requests (Ctrl+I, Ctrl+N, Ctrl+B, Ctrl+E).
426
+
427
+ Returns:
428
+ "continue" if an interrupt was handled and loop should continue
429
+ "paused" if workflow should pause
430
+ "none" if no interrupt was requested
431
+ """
432
+ state = engine.state
433
+
434
+ # Handle interrupt with feedback (Ctrl+I)
435
+ if app._interrupt_requested:
436
+ app.add_activity("Interrupted by user", "⏸️")
437
+
438
+ # Get feedback
439
+ feedback = await app.multiline_input_async(
440
+ "What needs to be fixed? (Ctrl+S to submit):", ""
441
+ )
442
+
443
+ # Get rollback target
444
+ valid_targets = engine.get_valid_interrupt_targets()
445
+ default_target = engine.get_default_interrupt_target()
446
+
447
+ if len(valid_targets) > 1:
448
+ options_text = "\n".join(
449
+ f" [{i + 1}] {s.value}" + (" (recommended)" if s == default_target else "")
450
+ for i, s in enumerate(valid_targets)
451
+ )
452
+ target_input = await app.text_input_async(
453
+ f"Roll back to which stage?\n\n{options_text}\n\nEnter number:", "1"
454
+ )
455
+ try:
456
+ target_idx = int(target_input or "1") - 1
457
+ target_stage = (
458
+ valid_targets[target_idx]
459
+ if 0 <= target_idx < len(valid_targets)
460
+ else default_target
461
+ )
462
+ except (ValueError, TypeError):
463
+ target_stage = default_target
464
+ else:
465
+ target_stage = valid_targets[0] if valid_targets else state.stage
466
+
467
+ # Send action to engine
468
+ result_event = engine.handle_action(
469
+ action(ActionType.INTERRUPT, feedback=feedback or "", target_stage=target_stage)
470
+ )
471
+
472
+ app._interrupt_requested = False
473
+ app._paused = False
474
+ app.show_message(
475
+ f"Interrupted - rolling back to {target_stage.value}",
476
+ "warning",
477
+ )
478
+ app.update_stage(state.stage.value, state.attempt)
479
+ return "continue"
480
+
481
+ # Handle skip stage (Ctrl+N)
482
+ if app._skip_stage_requested:
483
+ app.add_activity(f"Skipping {state.stage.value} stage", "⏭️")
484
+ skipped_stage = state.stage
485
+
486
+ result_event = engine.handle_action(action(ActionType.SKIP))
487
+
488
+ if result_event.type == EventType.WORKFLOW_COMPLETE:
489
+ app.show_message("Skipped to COMPLETE", "info")
490
+ else:
491
+ app.show_message(f"Skipped {skipped_stage.value} → {state.stage.value}", "info")
492
+ app.update_stage(state.stage.value, state.attempt)
493
+
494
+ app._skip_stage_requested = False
495
+ app._paused = False
496
+ return "continue"
497
+
498
+ # Handle back stage (Ctrl+B)
499
+ if app._back_stage_requested:
500
+ current_idx = STAGE_ORDER.index(state.stage)
501
+ if current_idx > 0:
502
+ result_event = engine.handle_action(action(ActionType.BACK))
503
+ app.add_activity(f"Going back to {state.stage.value}", "⏮️")
504
+ app.show_message(f"Back to {state.stage.value}", "info")
505
+ app.update_stage(state.stage.value, state.attempt)
506
+ else:
507
+ app.show_message("Already at first stage", "warning")
508
+
509
+ app._back_stage_requested = False
510
+ app._paused = False
511
+ return "continue"
512
+
513
+ # Handle manual edit pause (Ctrl+E)
514
+ if app._manual_edit_requested:
515
+ app.add_activity("Paused for manual editing", "✏️")
516
+ app.show_message("Workflow paused - make your edits, then press Enter to continue", "info")
517
+
518
+ await app.text_input_async("Press Enter when ready to continue...", "")
519
+
520
+ app.add_activity("Resuming workflow", "▶️")
521
+ app.show_message("Resuming...", "info")
522
+
523
+ app._manual_edit_requested = False
524
+ app._paused = False
525
+ return "continue"
526
+
527
+ return "none"
528
+
529
+
530
+ # =============================================================================
531
+ # PM Discovery Q&A functions
532
+ # =============================================================================
533
+
534
+
535
+ async def _run_pm_discovery(
536
+ app: WorkflowTUIApp,
537
+ state: WorkflowState,
538
+ skip_discovery: bool = False,
539
+ ) -> bool:
540
+ """
541
+ Run the PM discovery Q&A loop to refine the brief.
542
+
543
+ This function handles the interactive Q&A process before PM stage execution:
544
+ 1. Generate clarifying questions from the AI
545
+ 2. Present questions to user via TUI
546
+ 3. Collect answers
547
+ 4. Loop until user is satisfied
548
+ 5. Write DISCOVERY_LOG.md artifact
549
+
550
+ Args:
551
+ app: TUI application for user interaction.
552
+ state: Current workflow state to update with Q&A progress.
553
+ skip_discovery: If True, skip the Q&A loop entirely.
554
+
555
+ Returns:
556
+ True if discovery completed (or was skipped), False if user cancelled/quit.
557
+ """
558
+ # Check if discovery should be skipped
559
+ if skip_discovery or state.qa_complete:
560
+ if state.qa_complete:
561
+ app.show_message("Discovery Q&A already completed", "info")
562
+ return True
563
+
564
+ # Check if task type should skip discovery
565
+ config = get_config()
566
+ task_type_settings = config.task_type_settings.get(state.task_type.value)
567
+ if task_type_settings and task_type_settings.skip_discovery:
568
+ app.show_message(f"Discovery skipped for {state.task_type.display_name()} tasks", "info")
569
+ state.qa_complete = True
570
+ save_state(state)
571
+ return True
572
+
573
+ app.show_message("Starting brief discovery Q&A...", "info")
574
+ app.set_status("discovery", "refining brief")
575
+
576
+ qa_rounds: list[dict[str, Any]] = state.qa_rounds or []
577
+ builder = PromptBuilder()
578
+
579
+ while True:
580
+ # Generate questions
581
+ app.add_activity("Analyzing brief for clarifying questions...", "🔍")
582
+ questions = await _generate_discovery_questions(app, state, builder, qa_rounds)
583
+
584
+ if questions is None:
585
+ # AI invocation failed
586
+ app.show_message("Failed to generate questions", "error")
587
+ return False
588
+
589
+ if not questions:
590
+ # AI found no gaps in the brief - continue automatically
591
+ app.show_message("No clarifying questions needed", "success")
592
+ break
593
+
594
+ # Present questions and collect answers
595
+ app.add_activity(f"Asking {len(questions)} clarifying questions...", "❓")
596
+ answers = await app.question_answer_session_async(questions)
597
+
598
+ if answers is None:
599
+ # User cancelled
600
+ app.show_message("Discovery cancelled", "warning")
601
+ return False
602
+
603
+ # Store round
604
+ qa_rounds.append({"questions": questions, "answers": answers})
605
+ state.qa_rounds = qa_rounds
606
+ save_state(state)
607
+
608
+ # Update discovery log
609
+ _write_discovery_log(state.task_name, qa_rounds)
610
+
611
+ app.show_message(
612
+ f"Round {len(qa_rounds)} complete - {len(questions)} Q&As recorded", "success"
613
+ )
614
+
615
+ # Ask if user wants more questions
616
+ more_questions = await app.ask_yes_no_async("Got more questions?")
617
+ if not more_questions:
618
+ break
619
+
620
+ # Mark discovery complete
621
+ state.qa_complete = True
622
+ save_state(state)
623
+
624
+ if qa_rounds:
625
+ app.show_message(f"Discovery complete - {len(qa_rounds)} rounds of Q&A", "success")
626
+ else:
627
+ app.show_message("Discovery complete - no questions needed", "info")
628
+
629
+ return True
630
+
631
+
632
+ async def _generate_discovery_questions(
633
+ app: WorkflowTUIApp,
634
+ state: WorkflowState,
635
+ builder: PromptBuilder,
636
+ qa_history: list[dict[str, Any]],
637
+ ) -> list[str] | None:
638
+ """
639
+ Generate discovery questions by invoking the AI.
640
+
641
+ Returns:
642
+ List of questions, empty list if AI found no gaps, or None if failed.
643
+ """
644
+ from galangal.ai import get_backend_with_fallback
645
+ from galangal.config.loader import get_config
646
+ from galangal.ui.tui import TUIAdapter
647
+
648
+ prompt = builder.build_discovery_prompt(state, qa_history)
649
+ config = get_config()
650
+
651
+ # Log the prompt
652
+ logs_dir = get_task_dir(state.task_name) / "logs"
653
+ logs_dir.mkdir(parents=True, exist_ok=True)
654
+ round_num = len(qa_history) + 1
655
+ log_file = logs_dir / f"discovery_{round_num}.log"
656
+ with open(log_file, "w") as f:
657
+ f.write(f"=== Discovery Prompt (Round {round_num}) ===\n{prompt}\n\n")
658
+
659
+ # Run AI with fallback support
660
+ backend = get_backend_with_fallback(config.ai.default, config=config)
661
+ ui = TUIAdapter(app)
662
+ result = await asyncio.to_thread(
663
+ backend.invoke,
664
+ prompt=prompt,
665
+ timeout=300, # 5 minutes for question generation
666
+ max_turns=10,
667
+ ui=ui,
668
+ pause_check=lambda: app._paused,
669
+ )
670
+
671
+ # Log output
672
+ with open(log_file, "a") as f:
673
+ f.write(f"=== Output ===\n{result.output or result.message}\n")
674
+
675
+ if not result.success:
676
+ return None
677
+
678
+ # Parse questions from output
679
+ return _parse_discovery_questions(result.output or "")
680
+
681
+
682
+ def _parse_discovery_questions(output: str) -> list[str]:
683
+ """Parse questions from AI output."""
684
+ # Check for NO_QUESTIONS marker
685
+ if "# NO_QUESTIONS" in output or "#NO_QUESTIONS" in output:
686
+ return []
687
+
688
+ questions = []
689
+ lines = output.split("\n")
690
+ in_questions = False
691
+
692
+ for line in lines:
693
+ line = line.strip()
694
+
695
+ # Start capturing after DISCOVERY_QUESTIONS header
696
+ if "DISCOVERY_QUESTIONS" in line:
697
+ in_questions = True
698
+ continue
699
+
700
+ if in_questions and line:
701
+ # Match numbered questions (1. Question text)
702
+ import re
703
+
704
+ match = re.match(r"^\d+[\.\)]\s*(.+)$", line)
705
+ if match:
706
+ questions.append(match.group(1))
707
+ elif line.startswith("-"):
708
+ # Also accept bullet points
709
+ questions.append(line[1:].strip())
710
+
711
+ return questions
712
+
713
+
714
+ def _write_discovery_log(task_name: str, qa_rounds: list[dict[str, Any]]) -> None:
715
+ """Write or update DISCOVERY_LOG.md artifact."""
716
+ content_parts = ["# Discovery Log\n"]
717
+ content_parts.append("This log captures the Q&A from brief refinement.\n")
718
+
719
+ for i, round_data in enumerate(qa_rounds, 1):
720
+ content_parts.append(f"\n## Round {i}\n")
721
+ content_parts.append("\n### Questions\n")
722
+ for j, q in enumerate(round_data.get("questions", []), 1):
723
+ content_parts.append(f"{j}. {q}\n")
724
+ content_parts.append("\n### Answers\n")
725
+ for j, a in enumerate(round_data.get("answers", []), 1):
726
+ content_parts.append(f"{j}. {a}\n")
727
+
728
+ write_artifact("DISCOVERY_LOG.md", "".join(content_parts), task_name)
729
+
730
+
731
+ # =============================================================================
732
+ # Helper functions for workflow logic
733
+ # =============================================================================
734
+
735
+
736
+ def _build_preflight_error_message(message: str, details: str) -> str:
737
+ """Build error message for preflight failure modal."""
738
+ failed_lines = []
739
+ for line in details.split("\n"):
740
+ if line.strip().startswith("✗") or "Failed" in line or "Missing" in line or "Error" in line:
741
+ failed_lines.append(line.strip())
742
+
743
+ modal_message = "Preflight checks failed:\n\n"
744
+ if failed_lines:
745
+ modal_message += "\n".join(failed_lines[:10])
746
+ else:
747
+ modal_message += details[:500]
748
+ modal_message += "\n\nFix issues and retry?"
749
+
750
+ return modal_message
751
+
752
+
753
+ def _get_skip_reasons(
754
+ state: WorkflowState,
755
+ config: GalangalConfig,
756
+ ) -> dict[str, str]:
757
+ """
758
+ Get a mapping of stage names to their skip reasons.
759
+
760
+ Checks multiple skip sources in order of precedence:
761
+ 1. Task type skips (from TASK_TYPE_SKIP_STAGES)
762
+ 2. Config skips (from config.stages.skip)
763
+ 3. PM stage plan skips (from STAGE_PLAN.md)
764
+ 4. skip_if conditions (glob patterns for conditional stages)
765
+
766
+ Args:
767
+ state: Current workflow state with task_type and stage_plan.
768
+ config: Configuration with stages.skip list.
769
+
770
+ Returns:
771
+ Dict mapping stage name -> reason string (e.g., "task type: bug_fix")
772
+ """
773
+ skip_reasons: dict[str, str] = {}
774
+ task_type = state.task_type
775
+
776
+ # 1. Task type skips
777
+ for stage in TASK_TYPE_SKIP_STAGES.get(task_type, set()):
778
+ skip_reasons[stage.value] = f"task type: {task_type.value}"
779
+
780
+ # 2. Config skips
781
+ if config.stages.skip:
782
+ for stage_name in config.stages.skip:
783
+ stage_upper = stage_name.upper()
784
+ if stage_upper not in skip_reasons:
785
+ skip_reasons[stage_upper] = "config: stages.skip"
786
+
787
+ # 3. PM stage plan skips
788
+ if state.stage_plan:
789
+ for stage_name, plan_entry in state.stage_plan.items():
790
+ if plan_entry.get("action") == "skip":
791
+ reason = plan_entry.get("reason", "PM recommendation")
792
+ if stage_name not in skip_reasons:
793
+ skip_reasons[stage_name] = f"PM: {reason}"
794
+
795
+ # 4. skip_if conditions for conditional stages
796
+ # Only check stages not already skipped by other means
797
+ conditional_stages = get_conditional_stages()
798
+ runner = ValidationRunner()
799
+
800
+ for stage in conditional_stages:
801
+ if stage.value not in skip_reasons:
802
+ if runner.should_skip_stage(stage.value, state.task_name):
803
+ # Get the skip_if pattern for context
804
+ stage_config = getattr(config.validation, stage.value.lower(), None)
805
+ if stage_config and stage_config.skip_if and stage_config.skip_if.no_files_match:
806
+ patterns = stage_config.skip_if.no_files_match
807
+ if isinstance(patterns, str):
808
+ patterns = [patterns]
809
+ pattern_str = ", ".join(patterns[:2])
810
+ if len(patterns) > 2:
811
+ pattern_str += "..."
812
+ skip_reasons[stage.value] = f"no files match: {pattern_str}"
813
+ else:
814
+ skip_reasons[stage.value] = "skip_if condition"
815
+
816
+ return skip_reasons
817
+
818
+
819
+ async def _show_stage_preview(
820
+ app: WorkflowTUIApp,
821
+ state: WorkflowState,
822
+ config: GalangalConfig,
823
+ ) -> str:
824
+ """
825
+ Show a preview of stages to run before continuing.
826
+
827
+ Displays which stages will run and which will be skipped, with
828
+ annotated reasons for each skip (task type, config, PM plan, skip_if).
829
+
830
+ Returns "continue" or "quit".
831
+ """
832
+ # Get all skip reasons (includes skip_if conditions)
833
+ skip_reasons = _get_skip_reasons(state, config)
834
+
835
+ # Update hidden stages to include skip_if-based skips
836
+ # This ensures the progress bar reflects the preview
837
+ current_hidden = set(app._hidden_stages)
838
+ new_hidden = current_hidden | set(skip_reasons.keys())
839
+ if new_hidden != current_hidden:
840
+ app.update_hidden_stages(frozenset(new_hidden))
841
+
842
+ # Calculate stages to run vs skip
843
+ all_stages = [s for s in STAGE_ORDER if s != Stage.COMPLETE]
844
+ stages_to_run = [s for s in all_stages if s.value not in new_hidden]
845
+ stages_skipped = [s for s in all_stages if s.value in new_hidden]
846
+
847
+ # Build preview message
848
+ run_str = " → ".join(s.value for s in stages_to_run)
849
+
850
+ # Build annotated skip list
851
+ if stages_skipped:
852
+ skip_lines = []
853
+ for stage in stages_skipped:
854
+ reason = skip_reasons.get(stage.value, "")
855
+ if reason:
856
+ skip_lines.append(f" {stage.value} ({reason})")
857
+ else:
858
+ skip_lines.append(f" {stage.value}")
859
+ skip_str = "\n".join(skip_lines)
860
+ else:
861
+ skip_str = " None"
862
+
863
+ # Build a nice preview
864
+ preview = f"""Workflow Preview
865
+
866
+ Stages to run:
867
+ {run_str}
868
+
869
+ Skipping:
870
+ {skip_str}
871
+
872
+ Controls during execution:
873
+ ^N Skip stage ^B Back ^E Pause for edit ^I Interrupt"""
874
+
875
+ return await app.prompt_async(PromptType.STAGE_PREVIEW, preview)
876
+
877
+
878
+ async def _handle_max_retries_exceeded(
879
+ app: WorkflowTUIApp,
880
+ state: WorkflowState,
881
+ error_message: str,
882
+ max_retries: int,
883
+ ) -> str:
884
+ """Handle stage failure after max retries exceeded."""
885
+ error_preview = error_message[:800].strip()
886
+ if len(error_message) > 800:
887
+ error_preview += "..."
888
+
889
+ # Show error prominently in error panel
890
+ app.show_error(
891
+ f"Stage {state.stage.value} failed after {max_retries} attempts",
892
+ error_preview,
893
+ )
894
+
895
+ modal_message = (
896
+ f"Stage {state.stage.value} failed after {max_retries} attempts.\n\n"
897
+ f"Error:\n{error_preview}\n\n"
898
+ "What would you like to do?"
899
+ )
900
+
901
+ choice = await app.prompt_async(PromptType.STAGE_FAILURE, modal_message)
902
+
903
+ # Clear error panel when user makes a choice
904
+ app.clear_error()
905
+
906
+ if choice == "fix_in_dev":
907
+ feedback = await app.multiline_input_async(
908
+ "Describe what needs to be fixed (Ctrl+S to submit):", ""
909
+ )
910
+ feedback = feedback or "Fix the failing stage"
911
+
912
+ failing_stage = state.stage.value
913
+ state.stage = Stage.DEV
914
+ state.last_failure = (
915
+ f"Feedback from {failing_stage} failure: {feedback}\n\n"
916
+ f"Original error:\n{error_message[:1500]}"
917
+ )
918
+ state.reset_attempts(clear_failure=False)
919
+ app.show_message("Rolling back to DEV with feedback", "warning")
920
+ save_state(state)
921
+
922
+ return choice
923
+
924
+
925
+ async def _handle_stage_approval(
926
+ app: WorkflowTUIApp,
927
+ state: WorkflowState,
928
+ config: GalangalConfig,
929
+ approval_artifact: str,
930
+ ) -> bool:
931
+ """
932
+ Handle approval gate after a stage that requires approval.
933
+
934
+ Args:
935
+ app: TUI application for user interaction.
936
+ state: Current workflow state.
937
+ config: Galangal configuration.
938
+ approval_artifact: Name of the approval artifact to create (e.g., "APPROVAL.md").
939
+
940
+ Returns:
941
+ True if workflow should continue, False if rejected/quit.
942
+ """
943
+ default_approver = config.project.approver_name or ""
944
+ stage_name = state.stage.value
945
+
946
+ # Determine prompt type based on stage
947
+ prompt_type = PromptType.PLAN_APPROVAL # Default, works for most stages
948
+
949
+ choice = await app.prompt_async(prompt_type, f"Approve {stage_name} to continue?")
950
+
951
+ if choice == "yes":
952
+ name = await app.text_input_async("Enter approver name:", default_approver)
953
+ if name:
954
+ from galangal.core.utils import now_formatted
955
+
956
+ approval_content = f"""# {stage_name} Approval
957
+
958
+ - **Status:** Approved
959
+ - **Approved By:** {name}
960
+ - **Date:** {now_formatted()}
961
+ """
962
+ write_artifact(approval_artifact, approval_content, state.task_name)
963
+ app.show_message(f"{stage_name} approved by {name}", "success")
964
+
965
+ # PM-specific: Parse and store stage plan
966
+ if state.stage == Stage.PM:
967
+ stage_plan = parse_stage_plan(state.task_name)
968
+ if stage_plan:
969
+ state.stage_plan = stage_plan
970
+ save_state(state)
971
+ # Update progress bar to hide PM-skipped stages
972
+ skipped = [s for s, v in stage_plan.items() if v["action"] == "skip"]
973
+ if skipped:
974
+ new_hidden = set(app._hidden_stages) | set(skipped)
975
+ app.update_hidden_stages(frozenset(new_hidden))
976
+
977
+ # Show stage preview after PM approval
978
+ preview_result = await _show_stage_preview(app, state, config)
979
+ if preview_result == "quit":
980
+ app._workflow_result = "paused"
981
+ return False
982
+
983
+ return True
984
+ else:
985
+ # Cancelled - ask again
986
+ return await _handle_stage_approval(app, state, config, approval_artifact)
987
+
988
+ elif choice == "no":
989
+ reason = await app.multiline_input_async(
990
+ "Enter rejection reason (Ctrl+S to submit):", "Needs revision"
991
+ )
992
+ if reason:
993
+ state.last_failure = f"{stage_name} rejected: {reason}"
994
+ state.reset_attempts(clear_failure=False)
995
+ save_state(state)
996
+ app.show_message(f"{stage_name} rejected: {reason}", "warning")
997
+ app.show_message(f"Restarting {stage_name} stage with feedback...", "info")
998
+ return False
999
+ else:
1000
+ # Cancelled - ask again
1001
+ return await _handle_stage_approval(app, state, config, approval_artifact)
1002
+
1003
+ else: # quit
1004
+ app._workflow_result = "paused"
1005
+ return False
1006
+
1007
+
1008
+ async def _handle_workflow_complete(app: WorkflowTUIApp, state: WorkflowState) -> None:
1009
+ """Handle workflow completion - finalization and post-completion options."""
1010
+ # Clear fast-track state on completion
1011
+ state.clear_fast_track()
1012
+ state.clear_passed_stages()
1013
+ save_state(state)
1014
+
1015
+ app.show_workflow_complete()
1016
+ app.update_stage("COMPLETE")
1017
+ app.set_status("complete", "workflow finished")
1018
+
1019
+ choice = await app.prompt_async(PromptType.COMPLETION, "Workflow complete!")
1020
+
1021
+ if choice == "yes":
1022
+ # Run finalization
1023
+ app.set_status("finalizing", "creating PR...")
1024
+
1025
+ def progress_callback(message: str, status: str) -> None:
1026
+ app.show_message(message, status)
1027
+
1028
+ from galangal.commands.complete import finalize_task
1029
+
1030
+ success, pr_url = await asyncio.to_thread(
1031
+ finalize_task,
1032
+ state.task_name,
1033
+ state,
1034
+ force=True,
1035
+ progress_callback=progress_callback,
1036
+ )
1037
+
1038
+ if success:
1039
+ app.add_activity("")
1040
+ app.add_activity("[bold #b8bb26]Task completed successfully![/]", "✓")
1041
+ if pr_url and pr_url != "PR already exists":
1042
+ app.add_activity(f"[#83a598]PR: {pr_url}[/]", "")
1043
+ app.add_activity("")
1044
+
1045
+ # Show post-completion options
1046
+ completion_msg = "Task completed successfully!"
1047
+ if pr_url and pr_url.startswith("http"):
1048
+ completion_msg += f"\n\nPull Request:\n{pr_url}"
1049
+ completion_msg += "\n\nWhat would you like to do next?"
1050
+
1051
+ post_choice = await app.prompt_async(PromptType.POST_COMPLETION, completion_msg)
1052
+
1053
+ if post_choice == "new_task":
1054
+ app._workflow_result = "new_task"
1055
+ else:
1056
+ app._workflow_result = "done"
1057
+
1058
+ elif choice == "no":
1059
+ # Ask for feedback
1060
+ app.set_status("feedback", "waiting for input")
1061
+ feedback = await app.multiline_input_async(
1062
+ "What needs to be fixed? (Ctrl+S to submit):", ""
1063
+ )
1064
+
1065
+ if feedback:
1066
+ # Append to ROLLBACK.md (preserves history from earlier failures)
1067
+ from galangal.core.workflow.core import append_rollback_entry
1068
+
1069
+ append_rollback_entry(
1070
+ task_name=state.task_name,
1071
+ source="Manual review at COMPLETE stage",
1072
+ from_stage="COMPLETE",
1073
+ target_stage="DEV",
1074
+ reason=feedback,
1075
+ )
1076
+ state.last_failure = f"Manual review feedback: {feedback}"
1077
+ app.show_message("Feedback recorded, rolling back to DEV", "warning")
1078
+ else:
1079
+ state.last_failure = "Manual review requested changes (no details provided)"
1080
+ app.show_message("Rolling back to DEV (no feedback provided)", "warning")
1081
+
1082
+ state.stage = Stage.DEV
1083
+ state.reset_attempts(clear_failure=False)
1084
+ save_state(state)
1085
+ app._workflow_result = "back_to_dev"
1086
+
1087
+ else:
1088
+ app._workflow_result = "paused"
1089
+
1090
+
1091
+ def _start_new_task_tui() -> str:
1092
+ """
1093
+ Create a new task using TUI prompts for task type and description.
1094
+
1095
+ Returns:
1096
+ Result string indicating outcome.
1097
+ """
1098
+ app = WorkflowTUIApp("New Task", "SETUP", hidden_stages=frozenset())
1099
+
1100
+ task_info: dict[str, Any] = {
1101
+ "type": None,
1102
+ "description": None,
1103
+ "name": None,
1104
+ "github_issue": None,
1105
+ "github_repo": None,
1106
+ "screenshots": None,
1107
+ }
1108
+
1109
+ async def task_creation_loop() -> None:
1110
+ """Async task creation flow."""
1111
+ try:
1112
+ app.add_activity("[bold]Starting new task...[/bold]", "🆕")
1113
+
1114
+ # Step 0: Choose task source (manual or GitHub)
1115
+ app.set_status("setup", "select task source")
1116
+ source_choice = await app.prompt_async(PromptType.TASK_SOURCE, "Create task from:")
1117
+
1118
+ if source_choice == "quit":
1119
+ app._workflow_result = "cancelled"
1120
+ app.set_timer(0.5, app.exit)
1121
+ return
1122
+
1123
+ issue_body_for_screenshots = None
1124
+
1125
+ if source_choice == "github":
1126
+ # Handle GitHub issue selection
1127
+ app.set_status("setup", "checking GitHub")
1128
+ app.show_message("Checking GitHub setup...", "info")
1129
+
1130
+ try:
1131
+ from galangal.github.client import ensure_github_ready
1132
+ from galangal.github.issues import list_issues
1133
+
1134
+ check = await asyncio.to_thread(ensure_github_ready)
1135
+ if not check:
1136
+ app.show_message("GitHub not ready. Run 'galangal github check'", "error")
1137
+ app._workflow_result = "error"
1138
+ app.set_timer(0.5, app.exit)
1139
+ return
1140
+
1141
+ task_info["github_repo"] = check.repo_name
1142
+
1143
+ # List issues with galangal label
1144
+ app.set_status("setup", "fetching issues")
1145
+ app.show_message("Fetching issues...", "info")
1146
+
1147
+ issues = await asyncio.to_thread(list_issues)
1148
+ if not issues:
1149
+ app.show_message("No issues with 'galangal' label found", "warning")
1150
+ app._workflow_result = "cancelled"
1151
+ app.set_timer(0.5, app.exit)
1152
+ return
1153
+
1154
+ # Show issue selection
1155
+ app.set_status("setup", "select issue")
1156
+ issue_options = [(i.number, i.title) for i in issues]
1157
+ issue_num = await app.select_github_issue_async(issue_options)
1158
+
1159
+ if issue_num is None:
1160
+ app._workflow_result = "cancelled"
1161
+ app.set_timer(0.5, app.exit)
1162
+ return
1163
+
1164
+ # Get the selected issue details
1165
+ selected_issue = next((i for i in issues if i.number == issue_num), None)
1166
+ if selected_issue:
1167
+ task_info["github_issue"] = selected_issue.number
1168
+ task_info["description"] = (
1169
+ f"{selected_issue.title}\n\n{selected_issue.body}"
1170
+ )
1171
+ app.show_message(f"Selected issue #{selected_issue.number}", "success")
1172
+
1173
+ # Check for screenshots
1174
+ from galangal.github.images import extract_image_urls
1175
+
1176
+ images = extract_image_urls(selected_issue.body)
1177
+ if images:
1178
+ app.show_message(
1179
+ f"Found {len(images)} screenshot(s) in issue...", "info"
1180
+ )
1181
+ issue_body_for_screenshots = selected_issue.body
1182
+
1183
+ # Try to infer task type from labels
1184
+ type_hint = selected_issue.get_task_type_hint()
1185
+ if type_hint:
1186
+ task_info["type"] = TaskType.from_str(type_hint)
1187
+ app.show_message(
1188
+ f"Inferred type: {task_info['type'].display_name()}",
1189
+ "info",
1190
+ )
1191
+
1192
+ except Exception as e:
1193
+ from galangal.core.utils import debug_exception
1194
+
1195
+ debug_exception("GitHub integration failed in new task flow", e)
1196
+ app.show_message(f"GitHub error: {e}", "error")
1197
+ app._workflow_result = "error"
1198
+ app.set_timer(0.5, app.exit)
1199
+ return
1200
+
1201
+ # Step 1: Get task type (if not already set from GitHub labels)
1202
+ if task_info["type"] is None:
1203
+ app.set_status("setup", "select task type")
1204
+ type_choice = await app.prompt_async(PromptType.TASK_TYPE, "Select task type:")
1205
+
1206
+ if type_choice == "quit":
1207
+ app._workflow_result = "cancelled"
1208
+ app.set_timer(0.5, app.exit)
1209
+ return
1210
+
1211
+ # Map selection to TaskType
1212
+ task_info["type"] = TaskType.from_str(type_choice)
1213
+
1214
+ app.show_message(f"Task type: {task_info['type'].display_name()}", "success")
1215
+
1216
+ # Step 2: Get task description (if not from GitHub)
1217
+ if not task_info["description"]:
1218
+ app.set_status("setup", "enter description")
1219
+ description = await app.multiline_input_async(
1220
+ "Enter task description (Ctrl+S to submit):", ""
1221
+ )
1222
+
1223
+ if not description:
1224
+ app.show_message("Task creation cancelled", "warning")
1225
+ app._workflow_result = "cancelled"
1226
+ app.set_timer(0.5, app.exit)
1227
+ return
1228
+
1229
+ task_info["description"] = description
1230
+
1231
+ # Step 3: Generate task name
1232
+ app.set_status("setup", "generating task name")
1233
+ from galangal.commands.start import create_task
1234
+ from galangal.core.tasks import generate_unique_task_name
1235
+
1236
+ # Use prefix for GitHub issues
1237
+ prefix = f"issue-{task_info['github_issue']}" if task_info["github_issue"] else None
1238
+ task_info["name"] = await asyncio.to_thread(
1239
+ generate_unique_task_name, task_info["description"], prefix
1240
+ )
1241
+ app.show_message(f"Task name: {task_info['name']}", "info")
1242
+
1243
+ # Step 3.5: Download screenshots if from GitHub issue
1244
+ if issue_body_for_screenshots:
1245
+ app.set_status("setup", "downloading screenshots")
1246
+ try:
1247
+ from galangal.github.issues import download_issue_screenshots
1248
+
1249
+ task_dir = get_task_dir(task_info["name"])
1250
+ screenshot_paths = await asyncio.to_thread(
1251
+ download_issue_screenshots,
1252
+ issue_body_for_screenshots,
1253
+ task_dir,
1254
+ )
1255
+ if screenshot_paths:
1256
+ task_info["screenshots"] = screenshot_paths
1257
+ app.show_message(
1258
+ f"Downloaded {len(screenshot_paths)} screenshot(s)",
1259
+ "success",
1260
+ )
1261
+ except Exception as e:
1262
+ from galangal.core.utils import debug_exception
1263
+
1264
+ debug_exception("Screenshot download failed", e)
1265
+ app.show_message(f"Screenshot download failed: {e}", "warning")
1266
+ # Non-critical - continue without screenshots
1267
+
1268
+ # Step 4: Create the task
1269
+ app.set_status("setup", "creating task")
1270
+ success, message = await asyncio.to_thread(
1271
+ create_task,
1272
+ task_info["name"],
1273
+ task_info["description"],
1274
+ task_info["type"],
1275
+ task_info["github_issue"],
1276
+ task_info["github_repo"],
1277
+ task_info["screenshots"],
1278
+ )
1279
+
1280
+ if success:
1281
+ app.show_message(message, "success")
1282
+ app._workflow_result = "task_created"
1283
+
1284
+ # Mark issue as in-progress if from GitHub
1285
+ if task_info["github_issue"]:
1286
+ try:
1287
+ from galangal.github.issues import mark_issue_in_progress
1288
+
1289
+ await asyncio.to_thread(mark_issue_in_progress, task_info["github_issue"])
1290
+ app.show_message("Marked issue as in-progress", "info")
1291
+ except Exception as e:
1292
+ from galangal.core.utils import debug_exception
1293
+
1294
+ debug_exception("Failed to mark issue as in-progress", e)
1295
+ # Non-critical - continue anyway
1296
+ else:
1297
+ app.show_error("Task creation failed", message)
1298
+ app._workflow_result = "error"
1299
+
1300
+ except Exception as e:
1301
+ from galangal.core.utils import debug_exception
1302
+
1303
+ debug_exception("Task creation failed in new task flow", e)
1304
+ app.show_error("Task creation error", str(e))
1305
+ app._workflow_result = "error"
1306
+ finally:
1307
+ app.set_timer(0.5, app.exit)
1308
+
1309
+ # Start creation as async worker
1310
+ app.call_later(lambda: app.run_worker(task_creation_loop(), exclusive=True))
1311
+ app.run()
1312
+
1313
+ result = app._workflow_result or "cancelled"
1314
+
1315
+ if result == "task_created" and task_info["name"]:
1316
+ from galangal.core.state import load_state
1317
+
1318
+ new_state = load_state(task_info["name"])
1319
+ if new_state:
1320
+ return _run_workflow_with_tui(new_state)
1321
+
1322
+ return result