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,781 @@
1
+ """
2
+ Workflow engine - pure state machine logic separated from UI concerns.
3
+
4
+ The WorkflowEngine encapsulates all workflow state transitions and decision
5
+ logic, emitting events that describe what happened. The TUI layer subscribes
6
+ to these events and translates them to visual updates.
7
+
8
+ This separation enables:
9
+ - Testing workflow logic without UI
10
+ - Alternative UIs (CLI, web, etc.)
11
+ - Clearer reasoning about state transitions
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum, auto
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from galangal.config.loader import get_config
21
+ from galangal.config.schema import GalangalConfig
22
+ from galangal.core.artifacts import artifact_exists, parse_stage_plan, read_artifact, write_artifact
23
+ from galangal.core.state import (
24
+ STAGE_ORDER,
25
+ Stage,
26
+ WorkflowState,
27
+ get_decision_file_name,
28
+ get_decision_words,
29
+ save_state,
30
+ )
31
+ from galangal.core.workflow.core import (
32
+ append_rollback_entry,
33
+ archive_rollback_if_exists,
34
+ get_next_stage,
35
+ handle_rollback,
36
+ )
37
+ from galangal.core.workflow.core import (
38
+ execute_stage as _execute_stage,
39
+ )
40
+ from galangal.results import StageResult, StageResultType
41
+
42
+ if TYPE_CHECKING:
43
+ from galangal.ai.base import PauseCheck
44
+ from galangal.ui.tui import WorkflowTUIApp
45
+
46
+
47
+ # =============================================================================
48
+ # Workflow Events - what the engine tells the UI
49
+ # =============================================================================
50
+
51
+
52
+ class EventType(Enum):
53
+ """Types of events emitted by the workflow engine."""
54
+
55
+ # Stage lifecycle
56
+ STAGE_STARTED = auto()
57
+ STAGE_COMPLETED = auto()
58
+ STAGE_FAILED = auto()
59
+
60
+ # User interaction required
61
+ APPROVAL_REQUIRED = auto()
62
+ CLARIFICATION_REQUIRED = auto()
63
+ USER_DECISION_REQUIRED = auto()
64
+ MAX_RETRIES_EXCEEDED = auto()
65
+ PREFLIGHT_FAILED = auto()
66
+ ROLLBACK_BLOCKED = auto()
67
+
68
+ # State changes
69
+ ROLLBACK_TRIGGERED = auto()
70
+ STAGE_SKIPPED = auto()
71
+ WORKFLOW_COMPLETE = auto()
72
+ WORKFLOW_PAUSED = auto()
73
+
74
+ # Discovery Q&A
75
+ DISCOVERY_QUESTIONS = auto()
76
+ DISCOVERY_COMPLETE = auto()
77
+
78
+
79
+ @dataclass
80
+ class WorkflowEvent:
81
+ """
82
+ Event emitted by the workflow engine.
83
+
84
+ Events describe what happened in the workflow. The TUI layer
85
+ subscribes to these and translates them to visual updates.
86
+ """
87
+
88
+ type: EventType
89
+ stage: Stage | None = None
90
+ message: str = ""
91
+ data: dict[str, Any] = field(default_factory=dict)
92
+
93
+
94
+ def event(
95
+ event_type: EventType,
96
+ stage: Stage | None = None,
97
+ message: str = "",
98
+ **kwargs: Any,
99
+ ) -> WorkflowEvent:
100
+ """Factory function for creating events."""
101
+ return WorkflowEvent(type=event_type, stage=stage, message=message, data=kwargs)
102
+
103
+
104
+ # =============================================================================
105
+ # User Actions - what the UI tells the engine
106
+ # =============================================================================
107
+
108
+
109
+ class ActionType(Enum):
110
+ """Types of user actions sent to the workflow engine."""
111
+
112
+ # Stage control
113
+ CONTINUE = auto() # Proceed with workflow
114
+ RETRY = auto() # Retry current stage
115
+ SKIP = auto() # Skip current stage (Ctrl+N)
116
+ BACK = auto() # Go back to previous stage (Ctrl+B)
117
+
118
+ # Interrupt
119
+ INTERRUPT = auto() # Interrupt with feedback (Ctrl+I)
120
+ MANUAL_EDIT = auto() # Pause for editing (Ctrl+E)
121
+
122
+ # Approval/Decision
123
+ APPROVE = auto() # Approve stage
124
+ REJECT = auto() # Reject stage
125
+ VIEW_ARTIFACT = auto() # View full artifact content
126
+
127
+ # Workflow control
128
+ QUIT = auto() # Stop workflow
129
+ FIX_IN_DEV = auto() # Force rollback to DEV
130
+
131
+
132
+ @dataclass
133
+ class UserAction:
134
+ """
135
+ Action from the user to the workflow engine.
136
+
137
+ Actions tell the engine what the user wants to do.
138
+ """
139
+
140
+ type: ActionType
141
+ data: dict[str, Any] = field(default_factory=dict)
142
+
143
+
144
+ def action(action_type: ActionType, **kwargs: Any) -> UserAction:
145
+ """Factory function for creating actions."""
146
+ return UserAction(type=action_type, data=kwargs)
147
+
148
+
149
+ # =============================================================================
150
+ # Workflow Engine
151
+ # =============================================================================
152
+
153
+
154
+ class WorkflowEngine:
155
+ """
156
+ Pure state machine for workflow execution.
157
+
158
+ The engine encapsulates all workflow logic without any UI knowledge.
159
+ It receives user actions and emits events describing what happened.
160
+
161
+ Usage:
162
+ engine = WorkflowEngine(state)
163
+
164
+ # Execute a stage
165
+ event = engine.execute_current_stage(tui_app, pause_check)
166
+
167
+ # Handle the event based on its type
168
+ if event.type == EventType.APPROVAL_REQUIRED:
169
+ # Collect approval from user via UI
170
+ user_action = action(ActionType.APPROVE, approver="John")
171
+ event = engine.handle_action(user_action)
172
+
173
+ The TUI layer is responsible for:
174
+ - Displaying events visually
175
+ - Collecting user input
176
+ - Translating input to UserAction objects
177
+ """
178
+
179
+ def __init__(self, state: WorkflowState, config: GalangalConfig | None = None):
180
+ """
181
+ Initialize the workflow engine.
182
+
183
+ Args:
184
+ state: Current workflow state.
185
+ config: Optional config, loaded from project if not provided.
186
+ """
187
+ self.state = state
188
+ self.config = config or get_config()
189
+ self._pending_result: StageResult | None = None
190
+
191
+ @property
192
+ def current_stage(self) -> Stage:
193
+ """Get the current workflow stage."""
194
+ return self.state.stage
195
+
196
+ @property
197
+ def is_complete(self) -> bool:
198
+ """Check if workflow is complete."""
199
+ return self.state.stage == Stage.COMPLETE
200
+
201
+ @property
202
+ def max_retries(self) -> int:
203
+ """Get max retries from config."""
204
+ return self.config.stages.max_retries
205
+
206
+ def check_github_issue(self) -> WorkflowEvent | None:
207
+ """
208
+ Check if linked GitHub issue is still open.
209
+
210
+ Returns:
211
+ WorkflowEvent if issue was closed, None otherwise.
212
+ """
213
+ if not self.state.github_issue:
214
+ return None
215
+
216
+ try:
217
+ from galangal.github.issues import is_issue_open
218
+
219
+ if is_issue_open(self.state.github_issue) is False:
220
+ return event(
221
+ EventType.WORKFLOW_PAUSED,
222
+ message=f"GitHub issue #{self.state.github_issue} has been closed",
223
+ reason="github_issue_closed",
224
+ )
225
+ except Exception:
226
+ pass # Non-critical
227
+
228
+ return None
229
+
230
+ def start_stage_timer(self) -> None:
231
+ """Start timing the current stage if not already started."""
232
+ if not self.state.stage_start_time:
233
+ self.state.start_stage_timer()
234
+ save_state(self.state)
235
+
236
+ def execute_current_stage(
237
+ self,
238
+ tui_app: WorkflowTUIApp,
239
+ pause_check: PauseCheck | None = None,
240
+ ) -> WorkflowEvent:
241
+ """
242
+ Execute the current stage and return an event describing the result.
243
+
244
+ This is a blocking operation that invokes the AI backend.
245
+
246
+ Args:
247
+ tui_app: TUI app for progress display (passed to execute_stage).
248
+ pause_check: Callback returning True if pause requested.
249
+
250
+ Returns:
251
+ WorkflowEvent describing the execution result.
252
+ """
253
+ result = _execute_stage(self.state, tui_app=tui_app, pause_check=pause_check)
254
+ self._pending_result = result
255
+ return self._process_stage_result(result)
256
+
257
+ def _process_stage_result(self, result: StageResult) -> WorkflowEvent:
258
+ """Convert a StageResult to a WorkflowEvent."""
259
+ stage = self.state.stage
260
+
261
+ if result.type == StageResultType.PAUSED:
262
+ return event(EventType.WORKFLOW_PAUSED, stage=stage, reason="user_paused")
263
+
264
+ if result.success:
265
+ # Check if approval is needed
266
+ metadata = stage.metadata
267
+ if metadata.requires_approval and metadata.approval_artifact:
268
+ if not artifact_exists(metadata.approval_artifact, self.state.task_name):
269
+ return event(
270
+ EventType.APPROVAL_REQUIRED,
271
+ stage=stage,
272
+ artifact_name=metadata.approval_artifact,
273
+ )
274
+ return event(EventType.STAGE_COMPLETED, stage=stage, message=result.message)
275
+
276
+ # Handle different failure types
277
+ if result.type == StageResultType.PREFLIGHT_FAILED:
278
+ return event(
279
+ EventType.PREFLIGHT_FAILED,
280
+ stage=stage,
281
+ message=result.message,
282
+ details=result.output or "",
283
+ )
284
+
285
+ if result.type == StageResultType.CLARIFICATION_NEEDED:
286
+ questions = self._parse_questions()
287
+ return event(
288
+ EventType.CLARIFICATION_REQUIRED,
289
+ stage=stage,
290
+ questions=questions,
291
+ )
292
+
293
+ if result.type == StageResultType.USER_DECISION_NEEDED:
294
+ return event(
295
+ EventType.USER_DECISION_REQUIRED,
296
+ stage=stage,
297
+ message=result.message,
298
+ artifact_preview=(result.output or "")[:500],
299
+ full_content=result.output or "",
300
+ )
301
+
302
+ if result.type == StageResultType.ROLLBACK_REQUIRED:
303
+ # Try to process rollback
304
+ if handle_rollback(self.state, result):
305
+ return event(
306
+ EventType.ROLLBACK_TRIGGERED,
307
+ stage=stage,
308
+ message=result.message,
309
+ from_stage=stage,
310
+ to_stage=result.rollback_to,
311
+ )
312
+ else:
313
+ # Rollback was blocked (loop detection)
314
+ rollback_count = (
315
+ self.state.get_rollback_count(result.rollback_to) if result.rollback_to else 0
316
+ )
317
+ target = result.rollback_to.value if result.rollback_to else "None"
318
+
319
+ if rollback_count >= 3:
320
+ block_reason = f"Too many rollbacks to {target} ({rollback_count} in last hour)"
321
+ elif result.rollback_to is None:
322
+ block_reason = "Rollback target not specified in validation"
323
+ else:
324
+ block_reason = "Rollback blocked (unknown reason)"
325
+
326
+ return event(
327
+ EventType.ROLLBACK_BLOCKED,
328
+ stage=stage,
329
+ message=result.message,
330
+ block_reason=block_reason,
331
+ target_stage=target,
332
+ )
333
+
334
+ # Generic failure - check retries
335
+ error_message = result.output or result.message
336
+ self.state.record_failure(error_message)
337
+
338
+ if not self.state.can_retry(self.max_retries):
339
+ return event(
340
+ EventType.MAX_RETRIES_EXCEEDED,
341
+ stage=stage,
342
+ message=error_message,
343
+ attempts=self.state.attempt,
344
+ max_retries=self.max_retries,
345
+ )
346
+
347
+ return event(
348
+ EventType.STAGE_FAILED,
349
+ stage=stage,
350
+ message=error_message,
351
+ attempt=self.state.attempt,
352
+ max_retries=self.max_retries,
353
+ )
354
+
355
+ def _parse_questions(self) -> list[str]:
356
+ """Parse questions from QUESTIONS.md artifact."""
357
+ content = read_artifact("QUESTIONS.md", self.state.task_name)
358
+ if not content:
359
+ return []
360
+
361
+ import re
362
+
363
+ questions = []
364
+ for line in content.split("\n"):
365
+ line = line.strip()
366
+ if not line or line.startswith("# "):
367
+ continue
368
+
369
+ # Numbered or bulleted questions
370
+ match = re.match(r"^\d+[\.\)]\s*(.+)$", line)
371
+ if match:
372
+ questions.append(match.group(1))
373
+ elif line.startswith("- ") or line.startswith("* "):
374
+ questions.append(line[2:].strip())
375
+ elif line.startswith("## "):
376
+ questions.append(line[3:].strip())
377
+
378
+ return questions
379
+
380
+ def handle_action(
381
+ self, user_action: UserAction, tui_app: WorkflowTUIApp | None = None
382
+ ) -> WorkflowEvent:
383
+ """
384
+ Handle a user action and return the resulting event.
385
+
386
+ Args:
387
+ user_action: The action from the user.
388
+ tui_app: Optional TUI app for archive notifications.
389
+
390
+ Returns:
391
+ WorkflowEvent describing what happened.
392
+ """
393
+ action_type = user_action.type
394
+ data = user_action.data
395
+
396
+ if action_type == ActionType.CONTINUE:
397
+ return self._advance_to_next_stage(tui_app)
398
+
399
+ if action_type == ActionType.RETRY:
400
+ # Just continue - the loop will retry the same stage
401
+ save_state(self.state)
402
+ return event(
403
+ EventType.STAGE_STARTED,
404
+ stage=self.state.stage,
405
+ attempt=self.state.attempt,
406
+ )
407
+
408
+ if action_type == ActionType.SKIP:
409
+ return self._handle_skip()
410
+
411
+ if action_type == ActionType.BACK:
412
+ return self._handle_back()
413
+
414
+ if action_type == ActionType.INTERRUPT:
415
+ return self._handle_interrupt(
416
+ feedback=data.get("feedback", ""),
417
+ target_stage=data.get("target_stage"),
418
+ )
419
+
420
+ if action_type == ActionType.MANUAL_EDIT:
421
+ # Just return an event - TUI handles the pause
422
+ return event(EventType.WORKFLOW_PAUSED, reason="manual_edit")
423
+
424
+ if action_type == ActionType.APPROVE:
425
+ return self._handle_approval(
426
+ approved=True,
427
+ approver=data.get("approver", ""),
428
+ )
429
+
430
+ if action_type == ActionType.REJECT:
431
+ return self._handle_approval(
432
+ approved=False,
433
+ reason=data.get("reason", ""),
434
+ )
435
+
436
+ if action_type == ActionType.QUIT:
437
+ save_state(self.state)
438
+ return event(EventType.WORKFLOW_PAUSED, reason="user_quit")
439
+
440
+ if action_type == ActionType.FIX_IN_DEV:
441
+ return self._handle_fix_in_dev(data.get("error", ""), data.get("feedback", ""))
442
+
443
+ # Unknown action
444
+ return event(EventType.WORKFLOW_PAUSED, reason="unknown_action")
445
+
446
+ def _advance_to_next_stage(self, tui_app: WorkflowTUIApp | None = None) -> WorkflowEvent:
447
+ """Advance to the next stage after success."""
448
+ current = self.state.stage
449
+
450
+ # Record duration and passed stage
451
+ duration = self.state.record_stage_duration()
452
+ self.state.record_passed_stage(current)
453
+
454
+ # Archive rollback after successful DEV
455
+ if current == Stage.DEV and tui_app:
456
+ archive_rollback_if_exists(self.state.task_name, tui_app)
457
+ self.state.clear_passed_stages()
458
+
459
+ # Find next stage
460
+ next_stage = get_next_stage(current, self.state)
461
+ skipped_stages = self._get_skipped_stages(current, next_stage)
462
+
463
+ if next_stage:
464
+ self.state.stage = next_stage
465
+ self.state.reset_attempts()
466
+ self.state.awaiting_approval = False
467
+ self.state.clarification_required = False
468
+ save_state(self.state)
469
+
470
+ return event(
471
+ EventType.STAGE_STARTED,
472
+ stage=next_stage,
473
+ attempt=self.state.attempt,
474
+ skipped_stages=skipped_stages,
475
+ duration=duration,
476
+ )
477
+ else:
478
+ self.state.stage = Stage.COMPLETE
479
+ self.state.clear_fast_track()
480
+ self.state.clear_passed_stages()
481
+ save_state(self.state)
482
+ return event(EventType.WORKFLOW_COMPLETE, duration=duration)
483
+
484
+ def _get_skipped_stages(self, current: Stage, next_stage: Stage | None) -> list[Stage]:
485
+ """Get list of stages that were skipped between current and next."""
486
+ if next_stage is None:
487
+ return []
488
+
489
+ current_idx = STAGE_ORDER.index(current)
490
+ next_idx = STAGE_ORDER.index(next_stage)
491
+
492
+ if next_idx > current_idx + 1:
493
+ return STAGE_ORDER[current_idx + 1 : next_idx]
494
+ return []
495
+
496
+ def _handle_skip(self) -> WorkflowEvent:
497
+ """Handle skip stage action (Ctrl+N)."""
498
+ skipped_stage = self.state.stage
499
+ next_stage = get_next_stage(self.state.stage, self.state)
500
+
501
+ if next_stage:
502
+ self.state.stage = next_stage
503
+ self.state.reset_attempts()
504
+ save_state(self.state)
505
+ return event(
506
+ EventType.STAGE_SKIPPED,
507
+ stage=skipped_stage,
508
+ next_stage=next_stage,
509
+ )
510
+ else:
511
+ self.state.stage = Stage.COMPLETE
512
+ save_state(self.state)
513
+ return event(EventType.WORKFLOW_COMPLETE)
514
+
515
+ def _handle_back(self) -> WorkflowEvent:
516
+ """Handle back stage action (Ctrl+B)."""
517
+ current_idx = STAGE_ORDER.index(self.state.stage)
518
+ if current_idx > 0:
519
+ prev_stage = STAGE_ORDER[current_idx - 1]
520
+ self.state.stage = prev_stage
521
+ self.state.reset_attempts()
522
+ save_state(self.state)
523
+ return event(
524
+ EventType.STAGE_STARTED,
525
+ stage=prev_stage,
526
+ attempt=self.state.attempt,
527
+ )
528
+ else:
529
+ # Already at first stage
530
+ return event(
531
+ EventType.STAGE_STARTED,
532
+ stage=self.state.stage,
533
+ message="Already at first stage",
534
+ )
535
+
536
+ def _handle_interrupt(self, feedback: str, target_stage: Stage | None) -> WorkflowEvent:
537
+ """Handle interrupt with feedback (Ctrl+I)."""
538
+ interrupted_stage = self.state.stage
539
+
540
+ # Determine valid rollback targets
541
+ current_idx = STAGE_ORDER.index(interrupted_stage)
542
+ valid_targets = [s for s in STAGE_ORDER[:current_idx] if s != Stage.PREFLIGHT]
543
+
544
+ # Use provided target or determine default
545
+ if target_stage is None:
546
+ if interrupted_stage == Stage.PM:
547
+ target_stage = Stage.PM
548
+ elif interrupted_stage == Stage.DESIGN:
549
+ target_stage = Stage.PM
550
+ else:
551
+ target_stage = Stage.DEV
552
+
553
+ # Validate target is in valid targets
554
+ if valid_targets and target_stage not in valid_targets:
555
+ target_stage = valid_targets[0] if valid_targets else interrupted_stage
556
+
557
+ # Append to ROLLBACK.md
558
+ append_rollback_entry(
559
+ task_name=self.state.task_name,
560
+ source=f"User interrupt (Ctrl+I) during {interrupted_stage.value}",
561
+ from_stage=interrupted_stage.value,
562
+ target_stage=target_stage.value,
563
+ reason=feedback or "No details provided",
564
+ )
565
+
566
+ self.state.stage = target_stage
567
+ self.state.last_failure = f"Interrupt feedback from {interrupted_stage.value}: {feedback}"
568
+ self.state.reset_attempts(clear_failure=False)
569
+ save_state(self.state)
570
+
571
+ return event(
572
+ EventType.ROLLBACK_TRIGGERED,
573
+ stage=interrupted_stage,
574
+ message=f"Interrupted - rolling back to {target_stage.value}",
575
+ from_stage=interrupted_stage,
576
+ to_stage=target_stage,
577
+ )
578
+
579
+ def _handle_approval(
580
+ self, approved: bool, approver: str = "", reason: str = ""
581
+ ) -> WorkflowEvent:
582
+ """Handle approval decision."""
583
+ stage = self.state.stage
584
+ metadata = stage.metadata
585
+ approval_artifact = metadata.approval_artifact
586
+
587
+ if approved and approver:
588
+ from galangal.core.utils import now_formatted
589
+
590
+ content = f"""# {stage.value} Approval
591
+
592
+ - **Status:** Approved
593
+ - **Approved By:** {approver}
594
+ - **Date:** {now_formatted()}
595
+ """
596
+ if approval_artifact:
597
+ write_artifact(approval_artifact, content, self.state.task_name)
598
+
599
+ # PM-specific: Parse and store stage plan
600
+ if stage == Stage.PM:
601
+ stage_plan = parse_stage_plan(self.state.task_name)
602
+ if stage_plan:
603
+ self.state.stage_plan = stage_plan
604
+ save_state(self.state)
605
+
606
+ return event(
607
+ EventType.STAGE_COMPLETED,
608
+ stage=stage,
609
+ message=f"Approved by {approver}",
610
+ approver=approver,
611
+ )
612
+
613
+ else:
614
+ # Rejected
615
+ self.state.last_failure = f"{stage.value} rejected: {reason}"
616
+ self.state.reset_attempts(clear_failure=False)
617
+ save_state(self.state)
618
+
619
+ return event(
620
+ EventType.STAGE_FAILED,
621
+ stage=stage,
622
+ message=f"Rejected: {reason}",
623
+ reason=reason,
624
+ )
625
+
626
+ def _handle_fix_in_dev(self, error: str, feedback: str = "") -> WorkflowEvent:
627
+ """Force rollback to DEV with feedback."""
628
+ original_stage = self.state.stage.value
629
+
630
+ # Clear rollback history for DEV to allow rollback
631
+ self.state.rollback_history = [
632
+ r for r in self.state.rollback_history if r.to_stage != Stage.DEV.value
633
+ ]
634
+
635
+ self.state.stage = Stage.DEV
636
+ self.state.last_failure = (
637
+ f"Manual rollback from {original_stage}: {feedback or error[:500]}"
638
+ )
639
+ self.state.reset_attempts(clear_failure=False)
640
+ save_state(self.state)
641
+
642
+ return event(
643
+ EventType.ROLLBACK_TRIGGERED,
644
+ message="Manual rollback to DEV",
645
+ from_stage=Stage.from_str(original_stage),
646
+ to_stage=Stage.DEV,
647
+ )
648
+
649
+ def handle_user_decision(
650
+ self,
651
+ choice: str,
652
+ tui_app: WorkflowTUIApp | None = None,
653
+ ) -> WorkflowEvent:
654
+ """
655
+ Handle user decision for stages requiring manual approval.
656
+
657
+ Args:
658
+ choice: "approve", "reject", or "quit"
659
+ tui_app: Optional TUI app for archive notifications.
660
+
661
+ Returns:
662
+ WorkflowEvent describing the result.
663
+ """
664
+ from galangal.logging import workflow_logger
665
+
666
+ stage = self.state.stage
667
+
668
+ if choice == "approve":
669
+ # Write decision file
670
+ decision_file = get_decision_file_name(stage)
671
+ approve_word, _ = get_decision_words(stage)
672
+ if decision_file and approve_word:
673
+ write_artifact(decision_file, approve_word, self.state.task_name)
674
+ else:
675
+ decision_file = f"{stage.value.upper()}_DECISION"
676
+ write_artifact(decision_file, "APPROVE", self.state.task_name)
677
+
678
+ workflow_logger.user_decision(
679
+ stage=stage.value,
680
+ task_name=self.state.task_name,
681
+ decision="approve",
682
+ reason="decision file missing",
683
+ )
684
+
685
+ # Record and advance
686
+ self.state.record_stage_duration()
687
+ self.state.record_passed_stage(stage)
688
+
689
+ return self._advance_to_next_stage(tui_app)
690
+
691
+ elif choice == "reject":
692
+ # Write rejection decision
693
+ original_stage = stage.value
694
+ decision_file = get_decision_file_name(stage)
695
+ _, reject_word = get_decision_words(stage)
696
+ if decision_file and reject_word:
697
+ write_artifact(decision_file, reject_word, self.state.task_name)
698
+ else:
699
+ decision_file = f"{original_stage.upper()}_DECISION"
700
+ write_artifact(decision_file, "REQUEST_CHANGES", self.state.task_name)
701
+
702
+ workflow_logger.user_decision(
703
+ stage=original_stage,
704
+ task_name=self.state.task_name,
705
+ decision="reject",
706
+ reason="decision file missing",
707
+ )
708
+
709
+ # Rollback to DEV
710
+ self.state.last_failure = f"User rejected {original_stage} stage"
711
+ self.state.stage = Stage.DEV
712
+ self.state.reset_attempts(clear_failure=False)
713
+ save_state(self.state)
714
+
715
+ return event(
716
+ EventType.ROLLBACK_TRIGGERED,
717
+ stage=Stage.from_str(original_stage),
718
+ message="User rejected - rolling back to DEV",
719
+ from_stage=Stage.from_str(original_stage),
720
+ to_stage=Stage.DEV,
721
+ )
722
+
723
+ else: # quit
724
+ workflow_logger.user_decision(
725
+ stage=stage.value,
726
+ task_name=self.state.task_name,
727
+ decision="quit",
728
+ reason="decision file missing",
729
+ )
730
+ save_state(self.state)
731
+ return event(EventType.WORKFLOW_PAUSED, reason="user_quit")
732
+
733
+ def handle_clarification_answers(
734
+ self, questions: list[str], answers: list[str]
735
+ ) -> WorkflowEvent:
736
+ """
737
+ Handle answers to clarification questions.
738
+
739
+ Args:
740
+ questions: The questions that were asked.
741
+ answers: User's answers.
742
+
743
+ Returns:
744
+ WorkflowEvent to continue workflow.
745
+ """
746
+ # Write ANSWERS.md
747
+ lines = ["# Answers\n", "Responses to clarifying questions.\n\n"]
748
+ for i, (q, a) in enumerate(zip(questions, answers), 1):
749
+ lines.append(f"## Question {i}\n")
750
+ lines.append(f"**Q:** {q}\n\n")
751
+ lines.append(f"**A:** {a}\n\n")
752
+
753
+ write_artifact("ANSWERS.md", "".join(lines), self.state.task_name)
754
+
755
+ # Clear clarification flag
756
+ self.state.clarification_required = False
757
+ save_state(self.state)
758
+
759
+ return event(
760
+ EventType.STAGE_STARTED,
761
+ stage=self.state.stage,
762
+ message="Answers saved - resuming stage",
763
+ )
764
+
765
+ def get_valid_interrupt_targets(self) -> list[Stage]:
766
+ """Get valid rollback targets for interrupt."""
767
+ current_idx = STAGE_ORDER.index(self.state.stage)
768
+ valid: list[Stage] = [s for s in STAGE_ORDER[:current_idx] if s != Stage.PREFLIGHT]
769
+
770
+ if self.state.stage == Stage.PM:
771
+ return [Stage.PM]
772
+ return valid if valid else [self.state.stage]
773
+
774
+ def get_default_interrupt_target(self) -> Stage:
775
+ """Get default rollback target for interrupt."""
776
+ if self.state.stage == Stage.PM:
777
+ return Stage.PM
778
+ elif self.state.stage == Stage.DESIGN:
779
+ return Stage.PM
780
+ else:
781
+ return Stage.DEV