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,789 @@
1
+ """
2
+ Core workflow utilities - stage execution, rollback handling.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from galangal.ai import get_backend_for_stage
11
+ from galangal.ai.base import PauseCheck
12
+ from galangal.config.loader import get_config
13
+ from galangal.core.artifacts import artifact_exists, artifact_path, read_artifact, write_artifact
14
+ from galangal.core.state import (
15
+ STAGE_ORDER,
16
+ Stage,
17
+ WorkflowState,
18
+ get_conditional_stages,
19
+ get_task_dir,
20
+ save_state,
21
+ should_skip_for_task_type,
22
+ )
23
+ from galangal.core.utils import now_iso
24
+ from galangal.prompts.builder import PromptBuilder
25
+ from galangal.results import StageResult, StageResultType
26
+ from galangal.ui.tui import TUIAdapter
27
+ from galangal.validation.runner import ValidationRunner
28
+
29
+ if TYPE_CHECKING:
30
+ from galangal.ui.tui import WorkflowTUIApp
31
+
32
+
33
+ # Get conditional stages from metadata (cached at module load)
34
+ CONDITIONAL_STAGES: dict[Stage, str] = get_conditional_stages()
35
+
36
+
37
+ def _format_issues(issues: list[dict[str, Any]]) -> str:
38
+ """Format issues list into markdown."""
39
+ if not issues:
40
+ return ""
41
+
42
+ formatted = "\n\n## Issues Found\n\n"
43
+ for issue in issues:
44
+ severity = issue.get("severity", "unknown")
45
+ desc = issue.get("description", "")
46
+ file_ref = issue.get("file", "")
47
+ line = issue.get("line")
48
+ loc = f" ({file_ref}:{line})" if file_ref and line else ""
49
+ formatted += f"- **[{severity.upper()}]** {desc}{loc}\n"
50
+ return formatted
51
+
52
+
53
+ def _write_artifacts_from_readonly_output(
54
+ stage: Stage,
55
+ output: str,
56
+ task_name: str,
57
+ tui_app: WorkflowTUIApp,
58
+ ) -> None:
59
+ """
60
+ Write stage artifacts from read-only backend's structured JSON output.
61
+
62
+ Read-only backends (like Codex) cannot write files directly. Instead,
63
+ they return structured JSON which we post-process to create the expected
64
+ artifacts based on STAGE_ARTIFACT_SCHEMA.
65
+
66
+ Supports two modes:
67
+ 1. Schema-based: Uses STAGE_METADATA artifact_schema mapping
68
+ 2. Generic fallback: Looks for 'artifacts' array in JSON output
69
+
70
+ Args:
71
+ stage: The stage that was executed
72
+ output: JSON string containing structured output
73
+ task_name: Task name for artifact paths
74
+ tui_app: TUI app for activity logging
75
+ """
76
+ import json
77
+
78
+ try:
79
+ data = json.loads(output)
80
+ except json.JSONDecodeError:
81
+ tui_app.add_activity("Warning: Backend output is not valid JSON", "⚠️")
82
+ return
83
+
84
+ # Try schema-based artifact writing first
85
+ schema = stage.metadata.artifact_schema
86
+ if schema:
87
+ _write_schema_artifacts(data, schema, stage, task_name, tui_app)
88
+ return
89
+
90
+ # Fall back to generic artifacts array
91
+ artifacts = data.get("artifacts", [])
92
+ if artifacts:
93
+ _write_generic_artifacts(artifacts, task_name, tui_app)
94
+ else:
95
+ tui_app.add_activity(f"Warning: No artifact schema for {stage.value}", "⚠️")
96
+
97
+
98
+ def _write_schema_artifacts(
99
+ data: dict[str, Any],
100
+ schema: dict[str, str | None],
101
+ stage: Stage,
102
+ task_name: str,
103
+ tui_app: WorkflowTUIApp,
104
+ ) -> None:
105
+ """Write artifacts based on stage schema mapping."""
106
+ from galangal.core.state import get_decision_values
107
+
108
+ notes_file = schema.get("notes_file")
109
+ notes_field = schema.get("notes_field")
110
+ decision_file = schema.get("decision_file")
111
+ decision_field = schema.get("decision_field")
112
+ issues_field = schema.get("issues_field")
113
+
114
+ # Write notes file
115
+ if notes_file and notes_field:
116
+ notes = data.get(notes_field, "")
117
+ if notes:
118
+ # Append formatted issues if present
119
+ if issues_field:
120
+ issues = data.get(issues_field, [])
121
+ notes += _format_issues(issues)
122
+
123
+ write_artifact(notes_file, notes, task_name)
124
+ tui_app.add_activity(f"Wrote {notes_file} from backend output", "📝")
125
+
126
+ # Write decision file using stage-specific valid values
127
+ if decision_file and decision_field:
128
+ decision = data.get(decision_field, "")
129
+ # Get valid decisions from STAGE_METADATA
130
+ valid_decisions = get_decision_values(stage)
131
+ if decision in valid_decisions:
132
+ write_artifact(decision_file, decision, task_name)
133
+ tui_app.add_activity(f"Wrote {decision_file}: {decision}", "📝")
134
+ elif decision:
135
+ tui_app.add_activity(
136
+ f"Warning: Invalid decision '{decision}' for {stage.value} "
137
+ f"(expected: {', '.join(valid_decisions)})",
138
+ "⚠️",
139
+ )
140
+
141
+
142
+ def _write_generic_artifacts(
143
+ artifacts: list[dict[str, Any]],
144
+ task_name: str,
145
+ tui_app: WorkflowTUIApp,
146
+ ) -> None:
147
+ """Write artifacts from generic artifacts array in JSON output.
148
+
149
+ Expected format:
150
+ [
151
+ {"name": "ARTIFACT_NAME.md", "content": "..."},
152
+ ...
153
+ ]
154
+ """
155
+ for artifact in artifacts:
156
+ name = artifact.get("name")
157
+ content = artifact.get("content")
158
+ if name and content:
159
+ write_artifact(name, content, task_name)
160
+ tui_app.add_activity(f"Wrote {name} from backend output", "📝")
161
+
162
+
163
+ def get_next_stage(current: Stage, state: WorkflowState) -> Stage | None:
164
+ """
165
+ Determine the next stage in the workflow pipeline.
166
+
167
+ Iterates through STAGE_ORDER starting after current stage, skipping
168
+ stages that should be bypassed based on (in order):
169
+ 1. Config-level skipping (config.stages.skip)
170
+ 2. Task type skipping (e.g., DOCS tasks skip TEST, BENCHMARK)
171
+ 3. Fast-track skipping (minor rollback - skip stages that already passed)
172
+ 4. PM-driven stage plan (STAGE_PLAN.md recommendations)
173
+ 5. Manual skip artifacts (e.g., MIGRATION_SKIP.md from galangal skip-*)
174
+ 6. TEST_GATE: skip if not enabled or no tests configured
175
+ 7. skip_if conditions from validation config (glob-based skipping for all stages)
176
+
177
+ This is the single source of truth for skip logic. All skip decisions
178
+ happen here during planning, ensuring the progress bar accurately
179
+ reflects which stages will run.
180
+
181
+ Args:
182
+ current: The stage that just completed.
183
+ state: Current workflow state containing task_name and task_type.
184
+
185
+ Returns:
186
+ The next stage to execute, or None if current is the last stage.
187
+ """
188
+ config = get_config()
189
+ task_name = state.task_name
190
+ task_type = state.task_type
191
+ start_idx = STAGE_ORDER.index(current) + 1
192
+ config_skip_stages = [s.upper() for s in config.stages.skip]
193
+ runner = ValidationRunner() # Create once for all skip_if checks
194
+
195
+ for next_stage in STAGE_ORDER[start_idx:]:
196
+ # Check 1: config-level skipping
197
+ if next_stage.value in config_skip_stages:
198
+ continue
199
+
200
+ # Check 2: task type skipping
201
+ if should_skip_for_task_type(next_stage, task_type):
202
+ continue
203
+
204
+ # Check 3: fast-track skipping (minor rollback - skip stages that already passed)
205
+ if state.should_fast_track_skip(next_stage):
206
+ continue
207
+
208
+ # Check 4: PM-driven stage plan (STAGE_PLAN.md recommendations)
209
+ if state.stage_plan and next_stage.value in state.stage_plan:
210
+ if state.stage_plan[next_stage.value].get("action") == "skip":
211
+ continue
212
+
213
+ # Check 5: manual skip artifacts (e.g., MIGRATION_SKIP.md from galangal skip-*)
214
+ # Uses metadata as source of truth for which stages have skip artifacts
215
+ stage_metadata = next_stage.metadata
216
+ if stage_metadata.skip_artifact and artifact_exists(
217
+ stage_metadata.skip_artifact, task_name
218
+ ):
219
+ continue
220
+
221
+ # Check 6: TEST_GATE - skip if not enabled or no tests configured
222
+ if next_stage == Stage.TEST_GATE:
223
+ if not config.test_gate.enabled or not config.test_gate.tests:
224
+ continue
225
+
226
+ # Check 7: for conditional stages, if PM explicitly said "run", skip the glob check
227
+ if next_stage in CONDITIONAL_STAGES:
228
+ if state.stage_plan and next_stage.value in state.stage_plan:
229
+ if state.stage_plan[next_stage.value].get("action") == "run":
230
+ return next_stage # PM says run, skip the glob check
231
+
232
+ # Check 8: skip_if conditions for ALL stages (glob-based skipping)
233
+ # This is the single place where skip_if is evaluated
234
+ if runner.should_skip_stage(next_stage.value.upper(), task_name):
235
+ continue
236
+
237
+ return next_stage
238
+
239
+ return None
240
+
241
+
242
+ def _execute_test_gate(
243
+ state: WorkflowState,
244
+ tui_app: WorkflowTUIApp,
245
+ config: Any,
246
+ ) -> StageResult:
247
+ """
248
+ Execute the TEST_GATE stage - run configured test commands mechanically.
249
+
250
+ This is a non-AI stage that runs shell commands to verify tests pass.
251
+ All configured tests must pass for the stage to succeed.
252
+
253
+ Args:
254
+ state: Current workflow state.
255
+ tui_app: TUI app for progress display.
256
+ config: Galangal configuration with test_gate settings.
257
+
258
+ Returns:
259
+ StageResult indicating success or failure with rollback to DEV.
260
+ """
261
+ import subprocess
262
+
263
+ from galangal.config.loader import get_project_root
264
+ from galangal.logging import workflow_logger
265
+
266
+ task_name = state.task_name
267
+ test_config = config.test_gate
268
+ project_root = get_project_root()
269
+
270
+ tui_app.add_activity("Running test gate checks...", "🧪")
271
+
272
+ # Track results for each test suite
273
+ results: list[dict[str, Any]] = []
274
+ all_passed = True
275
+ failed_tests: list[str] = []
276
+
277
+ for test in test_config.tests:
278
+ tui_app.add_activity(f"Running: {test.name}", "▶")
279
+ tui_app.show_message(f"Test Gate: {test.name}", "info")
280
+
281
+ try:
282
+ result = subprocess.run(
283
+ test.command,
284
+ shell=True,
285
+ cwd=project_root,
286
+ capture_output=True,
287
+ text=True,
288
+ timeout=test.timeout,
289
+ )
290
+
291
+ passed = result.returncode == 0
292
+ output = result.stdout + result.stderr
293
+
294
+ # Truncate output for display
295
+ output_preview = output[:2000] if len(output) > 2000 else output
296
+
297
+ results.append({
298
+ "name": test.name,
299
+ "command": test.command,
300
+ "passed": passed,
301
+ "exit_code": result.returncode,
302
+ "output": output_preview,
303
+ })
304
+
305
+ if passed:
306
+ tui_app.add_activity(f"✓ {test.name} passed", "✅")
307
+ else:
308
+ tui_app.add_activity(f"✗ {test.name} failed (exit code {result.returncode})", "❌")
309
+ all_passed = False
310
+ failed_tests.append(test.name)
311
+
312
+ # Stop on first failure if fail_fast is enabled
313
+ if test_config.fail_fast:
314
+ tui_app.add_activity("Stopping (fail_fast enabled)", "⚠")
315
+ break
316
+
317
+ except subprocess.TimeoutExpired:
318
+ results.append({
319
+ "name": test.name,
320
+ "command": test.command,
321
+ "passed": False,
322
+ "exit_code": -1,
323
+ "output": f"Command timed out after {test.timeout} seconds",
324
+ })
325
+ tui_app.add_activity(f"✗ {test.name} timed out", "⏱️")
326
+ all_passed = False
327
+ failed_tests.append(test.name)
328
+
329
+ if test_config.fail_fast:
330
+ break
331
+
332
+ except Exception as e:
333
+ results.append({
334
+ "name": test.name,
335
+ "command": test.command,
336
+ "passed": False,
337
+ "exit_code": -1,
338
+ "output": f"Error running command: {e}",
339
+ })
340
+ tui_app.add_activity(f"✗ {test.name} error: {e}", "❌")
341
+ all_passed = False
342
+ failed_tests.append(test.name)
343
+
344
+ if test_config.fail_fast:
345
+ break
346
+
347
+ # Build TEST_GATE_RESULTS.md artifact
348
+ passed_count = sum(1 for r in results if r["passed"])
349
+ total_count = len(results)
350
+ status = "PASS" if all_passed else "FAIL"
351
+
352
+ artifact_lines = [
353
+ "# Test Gate Results\n",
354
+ f"\n**Status:** {status}",
355
+ f"\n**Passed:** {passed_count}/{total_count}\n",
356
+ ]
357
+
358
+ if not all_passed:
359
+ artifact_lines.append(f"\n**Failed Tests:** {', '.join(failed_tests)}\n")
360
+
361
+ artifact_lines.append("\n## Test Results\n")
362
+
363
+ for r in results:
364
+ status_icon = "✅" if r["passed"] else "❌"
365
+ artifact_lines.append(f"\n### {status_icon} {r['name']}\n")
366
+ artifact_lines.append(f"\n**Command:** `{r['command']}`\n")
367
+ artifact_lines.append(f"**Exit Code:** {r['exit_code']}\n")
368
+ if r["output"]:
369
+ artifact_lines.append(f"\n<details>\n<summary>Output</summary>\n\n```\n{r['output']}\n```\n\n</details>\n")
370
+
371
+ write_artifact("TEST_GATE_RESULTS.md", "".join(artifact_lines), task_name)
372
+ tui_app.add_activity("Wrote TEST_GATE_RESULTS.md", "📝")
373
+
374
+ # Write decision file
375
+ decision = "PASS" if all_passed else "FAIL"
376
+ write_artifact("TEST_GATE_DECISION", decision, task_name)
377
+
378
+ # Log result
379
+ workflow_logger.stage_completed(
380
+ stage="TEST_GATE",
381
+ task_name=task_name,
382
+ success=all_passed,
383
+ duration=0, # Duration tracked by caller
384
+ )
385
+
386
+ if all_passed:
387
+ message = f"All {total_count} test suite(s) passed"
388
+ tui_app.show_message(f"Test Gate: {message}", "success")
389
+ return StageResult.create_success(message)
390
+ else:
391
+ message = f"Test gate failed: {len(failed_tests)} test suite(s) failed"
392
+ tui_app.show_message(f"Test Gate: {message}", "error")
393
+ return StageResult.rollback_required(
394
+ message=message,
395
+ rollback_to=Stage.DEV,
396
+ output="\n".join(f"- {t}" for t in failed_tests),
397
+ )
398
+
399
+
400
+ def execute_stage(
401
+ state: WorkflowState,
402
+ tui_app: WorkflowTUIApp,
403
+ pause_check: PauseCheck | None = None,
404
+ ) -> StageResult:
405
+ """
406
+ Execute a single workflow stage and validate its output.
407
+
408
+ This function handles the full lifecycle of a stage execution:
409
+ 1. Check for pending clarifications (QUESTIONS.md without ANSWERS.md)
410
+ 2. For PREFLIGHT stage: run validation checks directly
411
+ 3. For other stages: build prompt, invoke AI backend, validate output
412
+
413
+ The prompt is built using PromptBuilder which merges default prompts
414
+ with project-specific overrides. Retry context is appended when
415
+ state.attempt > 1.
416
+
417
+ All prompts and outputs are logged to the task's logs/ directory.
418
+
419
+ Args:
420
+ state: Current workflow state containing stage, task_name, attempt count,
421
+ and last_failure for retry context.
422
+ tui_app: TUI application instance for displaying progress and messages.
423
+ pause_check: Optional callback that returns True if a pause was requested
424
+ (e.g., user pressed Ctrl+C). Passed to ClaudeBackend for graceful stop.
425
+
426
+ Returns:
427
+ StageResult with one of:
428
+ - SUCCESS: Stage completed and validated successfully
429
+ - PREFLIGHT_FAILED: Preflight checks failed
430
+ - VALIDATION_FAILED: Stage output failed validation
431
+ - ROLLBACK_REQUIRED: Validation indicated rollback needed
432
+ - CLARIFICATION_NEEDED: Questions pending without answers
433
+ - PAUSED/TIMEOUT/ERROR: AI execution issues
434
+ """
435
+ from galangal.logging import workflow_logger
436
+
437
+ stage = state.stage
438
+ task_name = state.task_name
439
+ config = get_config()
440
+ start_time = time.time()
441
+
442
+ # Log stage start
443
+ workflow_logger.stage_started(
444
+ stage=stage.value,
445
+ task_name=task_name,
446
+ attempt=state.attempt,
447
+ max_retries=config.stages.max_retries,
448
+ )
449
+
450
+ if stage == Stage.COMPLETE:
451
+ return StageResult.create_success("Workflow complete")
452
+
453
+ # NOTE: Skip conditions are checked in get_next_stage() which is the single
454
+ # source of truth for skip logic. By the time we reach execute_stage(),
455
+ # the stage has already been determined to not be skipped.
456
+
457
+ # Check for clarification
458
+ if artifact_exists("QUESTIONS.md", task_name) and not artifact_exists("ANSWERS.md", task_name):
459
+ state.clarification_required = True
460
+ save_state(state)
461
+ return StageResult.clarification_needed()
462
+
463
+ # PREFLIGHT runs validation directly
464
+ if stage == Stage.PREFLIGHT:
465
+ tui_app.add_activity("Running preflight checks...", "⚙")
466
+
467
+ runner = ValidationRunner()
468
+ result = runner.validate_stage("PREFLIGHT", task_name)
469
+
470
+ if result.success:
471
+ tui_app.show_message(f"Preflight: {result.message}", "success")
472
+ return StageResult.create_success(result.message)
473
+ else:
474
+ return StageResult.preflight_failed(
475
+ message=result.message,
476
+ details=result.output or "",
477
+ )
478
+
479
+ # TEST_GATE runs configured test commands mechanically (no AI)
480
+ if stage == Stage.TEST_GATE:
481
+ return _execute_test_gate(state, tui_app, config)
482
+
483
+ # Get backend first (needed for backend-specific prompts)
484
+ backend = get_backend_for_stage(stage, config, use_fallback=True)
485
+
486
+ # Build prompt
487
+ builder = PromptBuilder()
488
+
489
+ # For read-only backends on review-type stages, use minimal context
490
+ # This gives an unbiased review without Claude's interpretations
491
+ review_stages = {Stage.REVIEW, Stage.SECURITY, Stage.QA}
492
+ if backend.read_only and stage in review_stages:
493
+ prompt = builder.build_minimal_review_prompt(state, backend_name=backend.name)
494
+ tui_app.add_activity("Using minimal context for independent review", "📋")
495
+ else:
496
+ prompt = builder.build_full_prompt(stage, state, backend_name=backend.name)
497
+
498
+ # Add retry context
499
+ if state.attempt > 1 and state.last_failure:
500
+ retry_context = f"""
501
+ ## ⚠️ RETRY ATTEMPT {state.attempt}
502
+
503
+ The previous attempt failed with the following error:
504
+
505
+ ```
506
+ {state.last_failure[:1000]}
507
+ ```
508
+
509
+ Please fix the issue above before proceeding. Do not repeat the same mistake.
510
+ """
511
+ prompt = f"{prompt}\n\n{retry_context}"
512
+
513
+ # Set up log file for streaming output
514
+ logs_dir = get_task_dir(task_name) / "logs"
515
+ logs_dir.mkdir(parents=True, exist_ok=True)
516
+ log_file = logs_dir / f"{stage.value.lower()}_{state.attempt}.log"
517
+
518
+ # Write prompt header to log file
519
+ with open(log_file, "w") as f:
520
+ f.write(f"=== Prompt ===\n{prompt}\n\n")
521
+ f.write(f"=== Backend: {backend.name} ===\n")
522
+ f.write("=== Streaming Output ===\n")
523
+
524
+ tui_app.add_activity(f"Using {backend.name} backend", "🤖")
525
+
526
+ ui = TUIAdapter(tui_app)
527
+ max_turns = backend.config.max_turns if backend.config else 200
528
+ invoke_result = backend.invoke(
529
+ prompt=prompt,
530
+ timeout=config.stages.timeout,
531
+ max_turns=max_turns,
532
+ ui=ui,
533
+ pause_check=pause_check,
534
+ stage=stage.value,
535
+ log_file=str(log_file), # Pass log file for streaming
536
+ )
537
+
538
+ # Add completion marker to log
539
+ with open(log_file, "a") as f:
540
+ f.write(f"\n=== Result: {invoke_result.type.value} ===\n")
541
+ if invoke_result.message:
542
+ f.write(f"{invoke_result.message}\n")
543
+
544
+ # Return early if AI invocation failed
545
+ if not invoke_result.success:
546
+ return invoke_result
547
+
548
+ # Post-process for read-only backends (e.g., Codex)
549
+ # These backends return structured JSON instead of writing files directly
550
+ if backend.read_only and invoke_result.output:
551
+ _write_artifacts_from_readonly_output(stage, invoke_result.output, task_name, tui_app)
552
+
553
+ # Validate stage
554
+ tui_app.add_activity("Validating stage outputs...", "⚙")
555
+
556
+ runner = ValidationRunner()
557
+ result = runner.validate_stage(stage.value, task_name)
558
+
559
+ # Log validation details including rollback_to for debugging
560
+ with open(log_file, "a") as f:
561
+ f.write("\n=== Validation ===\n")
562
+ f.write(f"success: {result.success}\n")
563
+ f.write(f"message: {result.message}\n")
564
+ f.write(f"rollback_to: {result.rollback_to}\n")
565
+ f.write(f"skipped: {result.skipped}\n")
566
+ if result.output:
567
+ f.write(f"\n=== Validation Output ===\n{result.output}\n")
568
+
569
+ duration = time.time() - start_time
570
+
571
+ # Log validation result
572
+ workflow_logger.validation_result(
573
+ stage=stage.value,
574
+ task_name=task_name,
575
+ success=result.success,
576
+ message=result.message,
577
+ skipped=result.skipped,
578
+ )
579
+
580
+ if result.success:
581
+ tui_app.show_message(result.message, "success")
582
+ workflow_logger.stage_completed(
583
+ stage=stage.value,
584
+ task_name=task_name,
585
+ success=True,
586
+ duration=duration,
587
+ )
588
+ return StageResult.create_success(result.message, output=invoke_result.output)
589
+ else:
590
+ # Check if user decision is needed (decision file missing)
591
+ if result.needs_user_decision:
592
+ tui_app.add_activity("Decision file missing - user confirmation required", "❓")
593
+ return StageResult.user_decision_needed(
594
+ message=result.message,
595
+ artifact_content=result.output,
596
+ )
597
+
598
+ tui_app.show_message(result.message, "error")
599
+ workflow_logger.stage_failed(
600
+ stage=stage.value,
601
+ task_name=task_name,
602
+ error=result.message,
603
+ attempt=state.attempt,
604
+ )
605
+ # Check if rollback is required
606
+ if result.rollback_to:
607
+ rollback_type = "fast-track rollback" if result.is_fast_track else "rollback"
608
+ tui_app.add_activity(f"Triggering {rollback_type} to {result.rollback_to}", "🔄")
609
+ return StageResult.rollback_required(
610
+ message=result.message,
611
+ rollback_to=Stage.from_str(result.rollback_to),
612
+ output=invoke_result.output,
613
+ is_fast_track=result.is_fast_track,
614
+ )
615
+ # Log when rollback_to is not set (helps debug missing rollback)
616
+ tui_app.add_activity("Validation failed without rollback target", "⚠")
617
+ return StageResult.validation_failed(result.message)
618
+
619
+
620
+ def append_rollback_entry(
621
+ task_name: str,
622
+ source: str,
623
+ from_stage: str,
624
+ target_stage: str,
625
+ reason: str,
626
+ ) -> None:
627
+ """
628
+ Append a rollback entry to ROLLBACK.md, preserving history.
629
+
630
+ Creates a structured entry documenting the rollback event and appends it
631
+ to existing ROLLBACK.md content (or creates new file if none exists).
632
+
633
+ Args:
634
+ task_name: Name of the task.
635
+ source: Description of what triggered the rollback
636
+ (e.g., "User interrupt (Ctrl+I)", "Validation failure", "Manual review").
637
+ from_stage: Stage where the rollback was triggered.
638
+ target_stage: Stage to roll back to.
639
+ reason: Description of issues to fix.
640
+ """
641
+ rollback_entry = f"""
642
+ ---
643
+
644
+ ## {source}
645
+
646
+ **Date:** {now_iso()}
647
+ **From Stage:** {from_stage}
648
+ **Target Stage:** {target_stage}
649
+
650
+ ### Issues to Fix
651
+ {reason}
652
+ """
653
+
654
+ existing = read_artifact("ROLLBACK.md", task_name)
655
+ if existing:
656
+ new_content = existing + rollback_entry
657
+ else:
658
+ new_content = f"# Rollback Log\n\nThis file tracks issues that required rolling back to earlier stages.\n{rollback_entry}"
659
+
660
+ write_artifact("ROLLBACK.md", new_content, task_name)
661
+
662
+
663
+ def archive_rollback_if_exists(task_name: str, tui_app: WorkflowTUIApp) -> None:
664
+ """
665
+ Archive ROLLBACK.md to ROLLBACK_RESOLVED.md after DEV stage succeeds.
666
+
667
+ When validation failures or manual review trigger a rollback to DEV,
668
+ the issues are recorded in ROLLBACK.md. Once DEV completes successfully,
669
+ this function moves the rollback content to ROLLBACK_RESOLVED.md with
670
+ a resolution timestamp.
671
+
672
+ Multiple rollbacks are accumulated in ROLLBACK_RESOLVED.md, separated
673
+ by horizontal rules, providing a history of issues encountered.
674
+
675
+ Args:
676
+ task_name: Name of the task to archive rollback for.
677
+ tui_app: TUI app for displaying archive notification.
678
+ """
679
+ if not artifact_exists("ROLLBACK.md", task_name):
680
+ return
681
+
682
+ rollback_content = read_artifact("ROLLBACK.md", task_name) or ""
683
+ resolved_path = artifact_path("ROLLBACK_RESOLVED.md", task_name)
684
+
685
+ resolution_note = f"\n\n## Resolved: {now_iso()}\n\nIssues fixed by DEV stage.\n"
686
+
687
+ if resolved_path.exists():
688
+ existing = resolved_path.read_text()
689
+ resolved_path.write_text(existing + "\n---\n" + rollback_content + resolution_note)
690
+ else:
691
+ resolved_path.write_text(rollback_content + resolution_note)
692
+
693
+ rollback_path = artifact_path("ROLLBACK.md", task_name)
694
+ rollback_path.unlink()
695
+
696
+ tui_app.add_activity("Archived ROLLBACK.md → ROLLBACK_RESOLVED.md", "📋")
697
+
698
+
699
+ def handle_rollback(state: WorkflowState, result: StageResult) -> bool:
700
+ """
701
+ Process a rollback from validation failure.
702
+
703
+ When validation indicates a rollback is needed (e.g., QA fails and needs
704
+ to go back to DEV), this function:
705
+ 1. Checks if rollback is allowed (prevents infinite loops)
706
+ 2. Records the rollback in state history
707
+ 3. Appends the rollback details to ROLLBACK.md
708
+ 4. Updates the workflow state to target stage
709
+ 5. Resets attempt counter and records failure reason
710
+
711
+ The ROLLBACK.md file serves as context for the target stage, describing
712
+ what issues need to be fixed.
713
+
714
+ Rollback loop prevention: If too many rollbacks to the same stage occur
715
+ within the time window (default: 3 within 1 hour), the rollback is blocked
716
+ and returns False.
717
+
718
+ Args:
719
+ state: Current workflow state to update. Modified in place.
720
+ result: StageResult with type=ROLLBACK_REQUIRED and rollback_to set.
721
+ The message field describes what needs to be fixed.
722
+
723
+ Returns:
724
+ True if rollback was processed (result was ROLLBACK_REQUIRED type).
725
+ False if result was not a rollback or rollback was blocked due to
726
+ too many recent rollbacks to the same stage.
727
+ """
728
+ if result.type != StageResultType.ROLLBACK_REQUIRED or result.rollback_to is None:
729
+ return False
730
+
731
+ task_name = state.task_name
732
+ from_stage = state.stage
733
+ target_stage = result.rollback_to
734
+ reason = result.message
735
+
736
+ # Check for rollback loops
737
+ if not state.should_allow_rollback(target_stage):
738
+ from galangal.logging import workflow_logger
739
+
740
+ rollback_count = state.get_rollback_count(target_stage)
741
+ loop_msg = (
742
+ f"Rollback loop detected: {rollback_count} rollbacks to {target_stage.value} "
743
+ f"in the last hour. Manual intervention required."
744
+ )
745
+ workflow_logger.rollback(
746
+ from_stage=from_stage.value,
747
+ to_stage=target_stage.value,
748
+ task_name=task_name,
749
+ reason=f"BLOCKED: {loop_msg}",
750
+ )
751
+ return False
752
+
753
+ # Record rollback in state history
754
+ state.record_rollback(from_stage, target_stage, reason)
755
+
756
+ # Append to ROLLBACK.md
757
+ append_rollback_entry(
758
+ task_name=task_name,
759
+ source=f"Validation failure in {from_stage.value}",
760
+ from_stage=from_stage.value,
761
+ target_stage=target_stage.value,
762
+ reason=reason,
763
+ )
764
+
765
+ # Log rollback event
766
+ from galangal.logging import workflow_logger
767
+
768
+ workflow_logger.rollback(
769
+ from_stage=from_stage.value,
770
+ to_stage=target_stage.value,
771
+ task_name=task_name,
772
+ reason=reason,
773
+ )
774
+
775
+ # Handle fast-track vs full rollback
776
+ if result.is_fast_track:
777
+ # Minor rollback: skip stages that already passed
778
+ state.setup_fast_track()
779
+ else:
780
+ # Full rollback: re-run all stages
781
+ state.clear_fast_track()
782
+ state.clear_passed_stages()
783
+
784
+ state.stage = target_stage
785
+ state.last_failure = f"Rollback from {from_stage.value}: {reason}"
786
+ state.reset_attempts(clear_failure=False)
787
+ save_state(state)
788
+
789
+ return True