galangal-orchestrate 0.13.0__py3-none-any.whl → 0.13.4__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.
galangal/__init__.py CHANGED
@@ -19,7 +19,7 @@ from galangal.logging import (
19
19
  workflow_logger,
20
20
  )
21
21
 
22
- __version__ = "0.13.0"
22
+ __version__ = "0.13.4"
23
23
 
24
24
  __all__ = [
25
25
  # Exceptions
galangal/ai/claude.py CHANGED
@@ -83,55 +83,11 @@ class ClaudeBackend(AIBackend):
83
83
  """Invoke Claude Code with a prompt."""
84
84
  # State for output processing
85
85
  pending_tools: list[tuple[str, str]] = []
86
- log_handle = None
87
-
88
- # Open log file for streaming if provided
89
- if log_file:
90
- try:
91
- log_handle = open(log_file, "a")
92
- except OSError as e:
93
- logger.warning("failed_to_open_log_file", path=log_file, error=str(e))
94
-
95
- def should_log_line(line: str) -> bool:
96
- """Determine if a line should be logged (errors, warnings, results)."""
97
- if not line.strip():
98
- return False
99
- try:
100
- data = json.loads(line.strip())
101
- msg_type = data.get("type", "")
102
-
103
- # Always log errors and results
104
- if msg_type in ("error", "result"):
105
- return True
106
-
107
- # Log system messages (rate limiting, etc.)
108
- if msg_type == "system":
109
- return True
110
-
111
- # Log tool errors
112
- if msg_type == "user":
113
- content = data.get("message", {}).get("content", [])
114
- for item in content:
115
- if item.get("type") == "tool_result" and item.get("is_error"):
116
- return True
117
-
118
- return False
119
- except (json.JSONDecodeError, KeyError, TypeError):
120
- # Log non-JSON lines that look like errors
121
- lower = line.lower()
122
- return "error" in lower or "warning" in lower or "failed" in lower
123
86
 
124
87
  def on_output(line: str) -> None:
125
88
  """Process each output line."""
126
89
  if ui:
127
90
  ui.add_raw_line(line)
128
- # Only log errors, warnings, and results to file
129
- if log_handle and should_log_line(line):
130
- try:
131
- log_handle.write(line + "\n")
132
- log_handle.flush() # Ensure immediate write
133
- except OSError:
134
- pass
135
91
  self._process_stream_line(line, ui, pending_tools)
136
92
 
137
93
  def on_idle(elapsed: float) -> None:
@@ -160,6 +116,7 @@ class ClaudeBackend(AIBackend):
160
116
  idle_interval=3.0,
161
117
  poll_interval_active=0.05,
162
118
  poll_interval_idle=0.5,
119
+ output_file=log_file,
163
120
  )
164
121
 
165
122
  result = runner.run()
@@ -209,13 +166,6 @@ class ClaudeBackend(AIBackend):
209
166
 
210
167
  except Exception as e:
211
168
  return StageResult.error(f"Claude invocation error: {e}")
212
- finally:
213
- # Close log file handle
214
- if log_handle:
215
- try:
216
- log_handle.close()
217
- except OSError:
218
- pass
219
169
 
220
170
  def _process_stream_line(
221
171
  self,
galangal/ai/codex.py CHANGED
@@ -188,7 +188,7 @@ class CodexBackend(AIBackend):
188
188
  ui: Optional TUI for progress display
189
189
  pause_check: Optional callback for pause detection
190
190
  stage: Stage name for schema customization (e.g., "QA", "SECURITY")
191
- log_file: Optional path to log file (unused for Codex read-only)
191
+ log_file: Optional path to log file for streaming raw output
192
192
 
193
193
  Returns:
194
194
  StageResult with structured JSON in the output field
@@ -256,6 +256,7 @@ class CodexBackend(AIBackend):
256
256
  idle_interval=5.0,
257
257
  poll_interval_active=0.05,
258
258
  poll_interval_idle=0.5,
259
+ output_file=log_file,
259
260
  )
260
261
 
261
262
  result = runner.run()
galangal/ai/subprocess.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  import select
8
8
  import subprocess
9
9
  import time
10
+ from collections import deque
10
11
  from collections.abc import Callable
11
12
  from dataclasses import dataclass
12
13
  from enum import Enum
@@ -88,6 +89,8 @@ class SubprocessRunner:
88
89
  idle_interval: float = 3.0,
89
90
  poll_interval_active: float = 0.05,
90
91
  poll_interval_idle: float = 0.5,
92
+ max_output_chars: int | None = 1_000_000,
93
+ output_file: str | None = None,
91
94
  ):
92
95
  """
93
96
  Initialize the subprocess runner.
@@ -102,6 +105,8 @@ class SubprocessRunner:
102
105
  idle_interval: Seconds between idle callbacks
103
106
  poll_interval_active: Sleep between polls when receiving output
104
107
  poll_interval_idle: Sleep between polls when idle
108
+ max_output_chars: Max output chars kept in memory (None for unlimited)
109
+ output_file: Optional file path to stream full output
105
110
  """
106
111
  self.command = command
107
112
  self.timeout = timeout
@@ -112,6 +117,8 @@ class SubprocessRunner:
112
117
  self.idle_interval = idle_interval
113
118
  self.poll_interval_active = poll_interval_active
114
119
  self.poll_interval_idle = poll_interval_idle
120
+ self.max_output_chars = max_output_chars
121
+ self.output_file = output_file
115
122
 
116
123
  def run(self) -> RunResult:
117
124
  """
@@ -129,16 +136,39 @@ class SubprocessRunner:
129
136
  text=True,
130
137
  )
131
138
 
132
- output_lines: list[str] = []
139
+ output_buffer: deque[str] = deque()
140
+ output_chars = 0
141
+ output_handle = None
133
142
  start_time = time.time()
134
143
  last_idle_callback = start_time
135
144
 
145
+ if self.output_file:
146
+ try:
147
+ output_handle = open(self.output_file, "a", encoding="utf-8", buffering=1)
148
+ except OSError:
149
+ output_handle = None
150
+
151
+ def record_output(chunk: str) -> None:
152
+ nonlocal output_chars, output_handle
153
+ if output_handle:
154
+ try:
155
+ output_handle.write(chunk)
156
+ except OSError:
157
+ output_handle = None
158
+
159
+ output_buffer.append(chunk)
160
+ output_chars += len(chunk)
161
+ if self.max_output_chars is not None:
162
+ while output_chars > self.max_output_chars and output_buffer:
163
+ removed = output_buffer.popleft()
164
+ output_chars -= len(removed)
165
+
136
166
  try:
137
167
  while True:
138
168
  retcode = process.poll()
139
169
 
140
170
  # Read available output (non-blocking)
141
- had_output = self._read_output(process, output_lines)
171
+ had_output = self._read_output(process, record_output)
142
172
 
143
173
  # Update last idle callback time if we had output
144
174
  if had_output:
@@ -151,24 +181,30 @@ class SubprocessRunner:
151
181
  # Check for pause request
152
182
  if self.pause_check and self.pause_check():
153
183
  self._terminate_gracefully(process)
184
+ self._capture_remaining(process, record_output)
154
185
  if self.ui:
155
186
  self.ui.add_activity("Paused by user request", "⏸️")
156
187
  return RunResult(
157
188
  outcome=RunOutcome.PAUSED,
158
189
  exit_code=None,
159
- output="".join(output_lines),
190
+ output="".join(output_buffer),
160
191
  )
161
192
 
162
193
  # Check for timeout
163
194
  elapsed = time.time() - start_time
164
195
  if elapsed > self.timeout:
165
196
  process.kill()
197
+ try:
198
+ process.wait(timeout=5)
199
+ except subprocess.TimeoutExpired:
200
+ pass
201
+ self._capture_remaining(process, record_output)
166
202
  if self.ui:
167
203
  self.ui.add_activity(f"Timeout after {self.timeout}s", "❌")
168
204
  return RunResult(
169
205
  outcome=RunOutcome.TIMEOUT,
170
206
  exit_code=None,
171
- output="".join(output_lines),
207
+ output="".join(output_buffer),
172
208
  timeout_seconds=self.timeout,
173
209
  )
174
210
 
@@ -182,28 +218,33 @@ class SubprocessRunner:
182
218
  time.sleep(self.poll_interval_active if had_output else self.poll_interval_idle)
183
219
 
184
220
  # Capture any remaining output
185
- remaining = self._capture_remaining(process)
186
- if remaining:
187
- output_lines.append(remaining)
221
+ self._capture_remaining(process, record_output)
188
222
 
189
223
  return RunResult(
190
224
  outcome=RunOutcome.COMPLETED,
191
225
  exit_code=process.returncode,
192
- output="".join(output_lines),
226
+ output="".join(output_buffer),
193
227
  )
194
228
 
195
229
  except Exception:
196
230
  # Ensure process is terminated on any error
197
231
  try:
198
232
  process.kill()
233
+ process.wait(timeout=5)
199
234
  except Exception:
200
235
  pass
201
236
  raise
237
+ finally:
238
+ if output_handle:
239
+ try:
240
+ output_handle.close()
241
+ except OSError:
242
+ pass
202
243
 
203
244
  def _read_output(
204
245
  self,
205
246
  process: subprocess.Popen[str],
206
- output_lines: list[str],
247
+ record_output: Callable[[str], None],
207
248
  ) -> bool:
208
249
  """
209
250
  Read all available output lines (non-blocking).
@@ -225,7 +266,7 @@ class SubprocessRunner:
225
266
  if not line:
226
267
  break
227
268
 
228
- output_lines.append(line)
269
+ record_output(line)
229
270
  had_output = True
230
271
 
231
272
  if self.on_output:
@@ -245,10 +286,15 @@ class SubprocessRunner:
245
286
  except subprocess.TimeoutExpired:
246
287
  process.kill()
247
288
 
248
- def _capture_remaining(self, process: subprocess.Popen[str]) -> str:
289
+ def _capture_remaining(
290
+ self,
291
+ process: subprocess.Popen[str],
292
+ record_output: Callable[[str], None],
293
+ ) -> None:
249
294
  """Capture any remaining output after process completes."""
250
295
  try:
251
296
  remaining, _ = process.communicate(timeout=10)
252
- return remaining or ""
297
+ if remaining:
298
+ record_output(remaining)
253
299
  except (OSError, ValueError, subprocess.TimeoutExpired):
254
- return ""
300
+ return
@@ -303,6 +303,11 @@ def finalize_task(
303
303
  if dest.exists():
304
304
  dest = done_dir / f"{task_name}-{datetime.now().strftime('%Y%m%d%H%M%S')}"
305
305
 
306
+ # Remove logs directory before moving (not needed after completion)
307
+ logs_dir = task_dir / "logs"
308
+ if logs_dir.exists():
309
+ shutil.rmtree(logs_dir)
310
+
306
311
  report(f"Moving task to {dest.relative_to(project_root)}/...")
307
312
  shutil.move(str(task_dir), str(dest))
308
313
  clear_active_task()
@@ -130,6 +130,12 @@ def cmd_start(args: argparse.Namespace) -> int:
130
130
  if not require_initialized():
131
131
  return 1
132
132
 
133
+ # Check for newer version on PyPI
134
+ from galangal.core.version_check import check_and_prompt_update
135
+
136
+ if not check_and_prompt_update():
137
+ return 0 # User chose to quit to update
138
+
133
139
  # Check for new/missing config sections - prompt user if any found
134
140
  if not _check_config_updates():
135
141
  return 0 # User chose to quit and configure
galangal/config/schema.py CHANGED
@@ -2,7 +2,7 @@
2
2
  Configuration schema using Pydantic models.
3
3
  """
4
4
 
5
- from pydantic import BaseModel, Field
5
+ from pydantic import BaseModel, Field, field_validator
6
6
 
7
7
 
8
8
  class ProjectConfig(BaseModel):
@@ -59,6 +59,12 @@ class TestGateConfig(BaseModel):
59
59
  default=True, description="Stop on first test failure instead of running all"
60
60
  )
61
61
 
62
+ @field_validator("tests", mode="before")
63
+ @classmethod
64
+ def tests_none_to_list(cls, v: list[TestGateTest] | None) -> list[TestGateTest]:
65
+ """Convert None to empty list (YAML 'tests:' with only comments becomes null)."""
66
+ return v if v is not None else []
67
+
62
68
 
63
69
  class ValidationCommand(BaseModel):
64
70
  """A validation command configuration.
@@ -228,6 +234,12 @@ class LoggingConfig(BaseModel):
228
234
  default=None,
229
235
  description="Log file path (e.g., 'logs/galangal.jsonl'). If not set, logs only to console.",
230
236
  )
237
+ activity_file: str | None = Field(
238
+ default=None,
239
+ description=(
240
+ "Optional activity log file path. Supports {task_name} placeholder for per-task logs."
241
+ ),
242
+ )
231
243
  json_format: bool = Field(
232
244
  default=True, description="Output JSON format (False for pretty console format)"
233
245
  )
galangal/core/state.py CHANGED
@@ -884,6 +884,7 @@ class WorkflowState:
884
884
 
885
885
  Called when validation fails and triggers a rollback to an earlier stage.
886
886
  The history is used to detect rollback loops and prevent infinite retries.
887
+ Keeps only the last 50 events to prevent state growth.
887
888
 
888
889
  Args:
889
890
  from_stage: Stage that failed and triggered the rollback.
@@ -892,6 +893,9 @@ class WorkflowState:
892
893
  """
893
894
  event = RollbackEvent.create(from_stage, to_stage, reason)
894
895
  self.rollback_history.append(event)
896
+ # Keep only the last 50 events - plenty for the 1-hour window check
897
+ if len(self.rollback_history) > 50:
898
+ self.rollback_history = self.rollback_history[-50:]
895
899
 
896
900
  def should_allow_rollback(self, target_stage: Stage) -> bool:
897
901
  """
@@ -0,0 +1,238 @@
1
+ """
2
+ Version checking utilities for galangal-orchestrate.
3
+
4
+ Checks PyPI for newer versions and prompts users to update.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import urllib.request
11
+ from typing import NamedTuple
12
+
13
+ from galangal import __version__
14
+
15
+ PYPI_PACKAGE_NAME = "galangal-orchestrate"
16
+ PYPI_URL = f"https://pypi.org/pypi/{PYPI_PACKAGE_NAME}/json"
17
+ REQUEST_TIMEOUT = 3 # seconds
18
+
19
+
20
+ class VersionInfo(NamedTuple):
21
+ """Result of a version check."""
22
+
23
+ current: str
24
+ latest: str | None
25
+ update_available: bool
26
+ error: str | None = None
27
+
28
+
29
+ def parse_version(version: str) -> tuple[int, ...]:
30
+ """
31
+ Parse a version string into a tuple of integers for comparison.
32
+
33
+ Handles versions like "0.13.0", "1.2.3", etc.
34
+ Non-numeric parts (like "rc1", "beta") are ignored.
35
+
36
+ Args:
37
+ version: Version string (e.g., "0.13.0")
38
+
39
+ Returns:
40
+ Tuple of integers (e.g., (0, 13, 0))
41
+ """
42
+ parts = []
43
+ for part in version.split("."):
44
+ # Extract numeric portion only
45
+ numeric = ""
46
+ for char in part:
47
+ if char.isdigit():
48
+ numeric += char
49
+ else:
50
+ break
51
+ if numeric:
52
+ parts.append(int(numeric))
53
+ return tuple(parts)
54
+
55
+
56
+ def compare_versions(current: str, latest: str) -> int:
57
+ """
58
+ Compare two version strings.
59
+
60
+ Args:
61
+ current: Current version string
62
+ latest: Latest version string
63
+
64
+ Returns:
65
+ -1 if current < latest (update available)
66
+ 0 if current == latest (up to date)
67
+ 1 if current > latest (ahead, e.g., dev version)
68
+ """
69
+ current_parts = parse_version(current)
70
+ latest_parts = parse_version(latest)
71
+
72
+ # Pad shorter version with zeros
73
+ max_len = max(len(current_parts), len(latest_parts))
74
+ current_parts = current_parts + (0,) * (max_len - len(current_parts))
75
+ latest_parts = latest_parts + (0,) * (max_len - len(latest_parts))
76
+
77
+ if current_parts < latest_parts:
78
+ return -1
79
+ elif current_parts > latest_parts:
80
+ return 1
81
+ return 0
82
+
83
+
84
+ def get_latest_version() -> str | None:
85
+ """
86
+ Fetch the latest version from PyPI.
87
+
88
+ Returns:
89
+ Latest version string, or None if fetch failed.
90
+ """
91
+ try:
92
+ request = urllib.request.Request(
93
+ PYPI_URL,
94
+ headers={"Accept": "application/json"},
95
+ )
96
+ with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
97
+ data = json.loads(response.read().decode("utf-8"))
98
+ version = data.get("info", {}).get("version")
99
+ return str(version) if version else None
100
+ except Exception:
101
+ return None
102
+
103
+
104
+ def check_for_updates() -> VersionInfo:
105
+ """
106
+ Check if a newer version is available on PyPI.
107
+
108
+ Returns:
109
+ VersionInfo with current/latest versions and update status.
110
+ """
111
+ current = __version__
112
+ latest = get_latest_version()
113
+
114
+ if latest is None:
115
+ return VersionInfo(
116
+ current=current,
117
+ latest=None,
118
+ update_available=False,
119
+ error="Could not check PyPI",
120
+ )
121
+
122
+ update_available = compare_versions(current, latest) < 0
123
+
124
+ return VersionInfo(
125
+ current=current,
126
+ latest=latest,
127
+ update_available=update_available,
128
+ )
129
+
130
+
131
+ def get_update_command() -> str:
132
+ """
133
+ Get the pip command to update galangal-orchestrate.
134
+
135
+ Returns:
136
+ Command string for updating the package.
137
+ """
138
+ return f"pip install --upgrade {PYPI_PACKAGE_NAME}"
139
+
140
+
141
+ def check_and_prompt_update() -> bool:
142
+ """
143
+ Check for updates and prompt user if one is available.
144
+
145
+ This is designed to be called at the start of `galangal start`.
146
+ It's non-blocking on errors and doesn't interrupt workflow if
147
+ the user declines to update.
148
+
149
+ Returns:
150
+ True if user wants to continue (or no update needed).
151
+ False if user wants to quit to update.
152
+ """
153
+ from rich.prompt import Confirm
154
+
155
+ from galangal.ui.console import console, print_info
156
+
157
+ try:
158
+ info = check_for_updates()
159
+
160
+ if info.error:
161
+ # Silently skip if we can't reach PyPI
162
+ return True
163
+
164
+ if not info.update_available:
165
+ return True
166
+
167
+ # Show update notification
168
+ console.print()
169
+ print_info(
170
+ f"New version available: [cyan]{info.latest}[/cyan] "
171
+ f"(you have {info.current})"
172
+ )
173
+ console.print(f"[dim] Update with: {get_update_command()}[/dim]\n")
174
+
175
+ # Ask if they want to continue or quit to update
176
+ continue_anyway = Confirm.ask(
177
+ "Continue without updating?",
178
+ default=True,
179
+ )
180
+
181
+ if not continue_anyway:
182
+ # Offer to run the update
183
+ run_update = Confirm.ask(
184
+ "Run update now?",
185
+ default=True,
186
+ )
187
+
188
+ if run_update:
189
+ return _run_update()
190
+
191
+ console.print(f"\n[dim]Run: {get_update_command()}[/dim]")
192
+ return False
193
+
194
+ console.print() # Add spacing before TUI starts
195
+ return True
196
+
197
+ except Exception:
198
+ # Non-critical, don't interrupt task creation
199
+ return True
200
+
201
+
202
+ def _run_update() -> bool:
203
+ """
204
+ Run pip upgrade and return whether to continue.
205
+
206
+ Returns:
207
+ False (user should restart after update).
208
+ """
209
+ import subprocess
210
+ import sys
211
+
212
+ from galangal.ui.console import console, print_error, print_success
213
+
214
+ console.print("\n[dim]Running update...[/dim]")
215
+
216
+ try:
217
+ # Use sys.executable to ensure we use the same Python
218
+ result = subprocess.run(
219
+ [sys.executable, "-m", "pip", "install", "--upgrade", PYPI_PACKAGE_NAME],
220
+ capture_output=True,
221
+ text=True,
222
+ timeout=120,
223
+ )
224
+
225
+ if result.returncode == 0:
226
+ print_success("Update complete! Please restart galangal.")
227
+ else:
228
+ print_error(f"Update failed: {result.stderr[:200]}")
229
+ console.print(f"[dim]Try manually: {get_update_command()}[/dim]")
230
+
231
+ except subprocess.TimeoutExpired:
232
+ print_error("Update timed out")
233
+ console.print(f"[dim]Try manually: {get_update_command()}[/dim]")
234
+ except Exception as e:
235
+ print_error(f"Update failed: {e}")
236
+ console.print(f"[dim]Try manually: {get_update_command()}[/dim]")
237
+
238
+ return False # Always return False so user restarts
@@ -250,6 +250,8 @@ def _execute_test_gate(
250
250
  This is a non-AI stage that runs shell commands to verify tests pass.
251
251
  All configured tests must pass for the stage to succeed.
252
252
 
253
+ Output is streamed line-by-line to the TUI for real-time visibility.
254
+
253
255
  Args:
254
256
  state: Current workflow state.
255
257
  tui_app: TUI app for progress display.
@@ -259,6 +261,9 @@ def _execute_test_gate(
259
261
  StageResult indicating success or failure with rollback to DEV.
260
262
  """
261
263
  import subprocess
264
+ import threading
265
+ from collections import deque
266
+ from queue import Empty, Full, Queue
262
267
 
263
268
  from galangal.config.loader import get_project_root
264
269
  from galangal.logging import workflow_logger
@@ -266,6 +271,15 @@ def _execute_test_gate(
266
271
  task_name = state.task_name
267
272
  test_config = config.test_gate
268
273
  project_root = get_project_root()
274
+ logs_dir = get_task_dir(task_name) / "logs"
275
+ logs_dir.mkdir(parents=True, exist_ok=True)
276
+
277
+ max_output_chars = 200_000
278
+ output_queue_size = 200
279
+
280
+ def _sanitize_filename(name: str) -> str:
281
+ cleaned = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in name.lower())
282
+ return cleaned.strip("_") or "test"
269
283
 
270
284
  tui_app.add_activity("Running test gate checks...", "🧪")
271
285
 
@@ -274,75 +288,172 @@ def _execute_test_gate(
274
288
  all_passed = True
275
289
  failed_tests: list[str] = []
276
290
 
291
+ def stream_output(
292
+ proc: subprocess.Popen,
293
+ output_tail: deque[str],
294
+ queue: Queue,
295
+ log_handle: Any | None,
296
+ truncated_flag: list[bool],
297
+ ) -> None:
298
+ """Read process output, stream to queue, and keep a bounded tail."""
299
+ output_chars = 0
300
+ try:
301
+ for line in iter(proc.stdout.readline, ""):
302
+ if not line:
303
+ break
304
+ if log_handle:
305
+ try:
306
+ log_handle.write(line)
307
+ except OSError:
308
+ log_handle = None
309
+
310
+ output_tail.append(line)
311
+ output_chars += len(line)
312
+ while output_chars > max_output_chars and output_tail:
313
+ truncated_flag[0] = True
314
+ removed = output_tail.popleft()
315
+ output_chars -= len(removed)
316
+
317
+ try:
318
+ queue.put(line.rstrip("\n"), timeout=1)
319
+ except Full:
320
+ pass
321
+ finally:
322
+ try:
323
+ queue.put_nowait(None) # Signal completion
324
+ except Full:
325
+ pass
326
+
277
327
  for test in test_config.tests:
278
328
  tui_app.add_activity(f"Running: {test.name}", "▶")
279
329
  tui_app.show_message(f"Test Gate: {test.name}", "info")
280
330
 
331
+ output_tail: deque[str] = deque()
332
+ output_truncated = [False]
333
+ exit_code = -1
334
+ timed_out = False
335
+ log_handle = None
336
+ log_path = logs_dir / f"test_gate_{_sanitize_filename(test.name)}.log"
337
+
281
338
  try:
282
- result = subprocess.run(
339
+ try:
340
+ log_handle = open(log_path, "w", encoding="utf-8")
341
+ except OSError:
342
+ log_handle = None
343
+
344
+ # Start process with stdout/stderr combined and piped
345
+ proc = subprocess.Popen(
283
346
  test.command,
284
347
  shell=True,
285
348
  cwd=project_root,
286
- capture_output=True,
349
+ stdout=subprocess.PIPE,
350
+ stderr=subprocess.STDOUT,
287
351
  text=True,
288
- timeout=test.timeout,
352
+ bufsize=1, # Line buffered
289
353
  )
290
354
 
291
- passed = result.returncode == 0
292
- output = result.stdout + result.stderr
355
+ # Stream output in background thread
356
+ output_queue: Queue = Queue(maxsize=output_queue_size)
357
+ reader_thread = threading.Thread(
358
+ target=stream_output,
359
+ args=(proc, output_tail, output_queue, log_handle, output_truncated),
360
+ daemon=True,
361
+ )
362
+ reader_thread.start()
363
+
364
+ # Read from queue and display in TUI until process completes or timeout
365
+ start_time = time.time()
366
+ while True:
367
+ try:
368
+ line = output_queue.get(timeout=0.1)
369
+ if line is None:
370
+ break # Reader finished
371
+ # Stream each line to TUI (prefix with test name for context)
372
+ tui_app.add_activity(f" {line[:200]}", "│")
373
+ except Empty:
374
+ pass
375
+
376
+ # Check timeout
377
+ if time.time() - start_time > test.timeout:
378
+ proc.kill()
379
+ timed_out = True
380
+ break
293
381
 
294
- # Truncate output for display
295
- output_preview = output[:2000] if len(output) > 2000 else output
382
+ # Check if process finished
383
+ if proc.poll() is not None and output_queue.empty():
384
+ break
296
385
 
297
- results.append({
298
- "name": test.name,
299
- "command": test.command,
300
- "passed": passed,
301
- "exit_code": result.returncode,
302
- "output": output_preview,
303
- })
386
+ # Wait for reader thread to finish
387
+ reader_thread.join(timeout=1.0)
304
388
 
305
- if passed:
306
- tui_app.add_activity(f"✓ {test.name} passed", "✅")
389
+ # Get exit code - must call wait() to ensure returncode is populated
390
+ if timed_out:
391
+ exit_code = -1
307
392
  else:
308
- tui_app.add_activity(f"✗ {test.name} failed (exit code {result.returncode})", "❌")
309
- all_passed = False
310
- failed_tests.append(test.name)
393
+ try:
394
+ proc.wait(timeout=5) # Ensure process fully terminated
395
+ except subprocess.TimeoutExpired:
396
+ proc.kill()
397
+ proc.wait()
398
+ exit_code = proc.returncode if proc.returncode is not None else -1
311
399
 
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
400
+ except Exception as e:
401
+ output_tail.append(f"Error running command: {e}")
402
+ exit_code = -1
403
+ finally:
404
+ if log_handle:
405
+ try:
406
+ log_handle.close()
407
+ except OSError:
408
+ pass
409
+
410
+ # Process results - include bounded tail for artifact
411
+ output = "".join(output_tail)
412
+ if output_truncated[0]:
413
+ output = (
414
+ f"[Output truncated to last {max_output_chars} characters. "
415
+ f"Full log: {log_path}]\n\n{output}"
416
+ )
316
417
 
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
- })
418
+ if timed_out:
419
+ results.append(
420
+ {
421
+ "name": test.name,
422
+ "command": test.command,
423
+ "passed": False,
424
+ "exit_code": -1,
425
+ "output": f"Command timed out after {test.timeout} seconds\n\n{output}",
426
+ }
427
+ )
325
428
  tui_app.add_activity(f"✗ {test.name} timed out", "⏱️")
326
429
  all_passed = False
327
430
  failed_tests.append(test.name)
328
431
 
329
432
  if test_config.fail_fast:
330
433
  break
434
+ else:
435
+ passed = exit_code == 0
436
+ results.append(
437
+ {
438
+ "name": test.name,
439
+ "command": test.command,
440
+ "passed": passed,
441
+ "exit_code": exit_code,
442
+ "output": output,
443
+ }
444
+ )
331
445
 
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)
446
+ if passed:
447
+ tui_app.add_activity(f"✓ {test.name} passed", "✅")
448
+ else:
449
+ tui_app.add_activity(f" {test.name} failed (exit code {exit_code})", "❌")
450
+ all_passed = False
451
+ failed_tests.append(test.name)
343
452
 
344
- if test_config.fail_fast:
345
- break
453
+ # Stop on first failure if fail_fast is enabled
454
+ if test_config.fail_fast:
455
+ tui_app.add_activity("Stopping (fail_fast enabled)", "⚠")
456
+ break
346
457
 
347
458
  # Build TEST_GATE_RESULTS.md artifact
348
459
  passed_count = sum(1 for r in results if r["passed"])
@@ -366,7 +477,9 @@ def _execute_test_gate(
366
477
  artifact_lines.append(f"\n**Command:** `{r['command']}`\n")
367
478
  artifact_lines.append(f"**Exit Code:** {r['exit_code']}\n")
368
479
  if r["output"]:
369
- artifact_lines.append(f"\n<details>\n<summary>Output</summary>\n\n```\n{r['output']}\n```\n\n</details>\n")
480
+ artifact_lines.append(
481
+ f"\n<details>\n<summary>Output</summary>\n\n```\n{r['output']}\n```\n\n</details>\n"
482
+ )
370
483
 
371
484
  write_artifact("TEST_GATE_RESULTS.md", "".join(artifact_lines), task_name)
372
485
  tui_app.add_activity("Wrote TEST_GATE_RESULTS.md", "📝")
@@ -80,12 +80,16 @@ def _run_workflow_with_tui(state: WorkflowState) -> str:
80
80
 
81
81
  # Compute hidden stages based on task type and config
82
82
  hidden_stages = frozenset(get_hidden_stages_for_task_type(state.task_type, config.stages.skip))
83
+ activity_log_path = None
84
+ if config.logging.activity_file:
85
+ activity_log_path = config.logging.activity_file.format(task_name=state.task_name)
83
86
 
84
87
  app = WorkflowTUIApp(
85
88
  state.task_name,
86
89
  state.stage.value,
87
90
  hidden_stages=hidden_stages,
88
91
  stage_durations=state.stage_durations,
92
+ activity_log_path=activity_log_path,
89
93
  )
90
94
 
91
95
  # Create workflow engine
@@ -12,11 +12,13 @@ Implement all changes described in PLAN.md while satisfying the acceptance crite
12
12
 
13
13
  ### If ROLLBACK.md exists (Rollback Run):
14
14
  1. Read ROLLBACK.md - contains issues that MUST be fixed
15
- 2. Read the relevant report (QA_REPORT.md, SECURITY_CHECKLIST.md, or REVIEW_NOTES.md)
15
+ 2. Read the relevant report (QA_REPORT.md, SECURITY_CHECKLIST.md, REVIEW_NOTES.md, or TEST_GATE_RESULTS.md)
16
16
  3. Fix ALL issues documented in ROLLBACK.md
17
17
  4. Update DEVELOPMENT.md with fixes made
18
18
  5. Done - workflow continues to re-run validation
19
19
 
20
+ **CRITICAL - TEST_GATE Rollbacks:** If rolling back from TEST_GATE, you MUST fix ALL failing tests listed in TEST_GATE_RESULTS.md, **even if the tests were not modified by this task and appear to be pre-existing failures**. The workflow cannot proceed until all tests pass. Do not skip fixing a test because it seems "unrelated" - fix it anyway to unblock the pipeline.
21
+
20
22
  ### If DEVELOPMENT.md exists (Resuming):
21
23
  1. Read DEVELOPMENT.md to understand progress so far
22
24
  2. Continue from where the previous session left off
@@ -73,7 +75,7 @@ Update DEVELOPMENT.md after completing each significant piece of work:
73
75
  ## Important Rules
74
76
 
75
77
  - ONLY implement what's in PLAN.md - nothing more
76
- - Do NOT fix pre-existing issues unrelated to your task
78
+ - Do NOT fix pre-existing issues unrelated to your task (EXCEPTION: TEST_GATE failures - see above)
77
79
  - Follow existing patterns in the codebase
78
80
  - Keep changes minimal and focused
79
81
  - Do NOT write tests - the TEST stage handles that
galangal/ui/tui/app.py CHANGED
@@ -22,6 +22,7 @@ Layout:
22
22
  import asyncio
23
23
  import threading
24
24
  import time
25
+ from collections import deque
25
26
  from collections.abc import Callable
26
27
  from pathlib import Path
27
28
 
@@ -86,6 +87,8 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
86
87
  _paused: Set to True when user requests pause.
87
88
  _workflow_result: Result string set by workflow thread.
88
89
  """
90
+ ACTIVITY_LOG_MAX_ENTRIES = 5000
91
+ RICH_LOG_MAX_LINES = 1000
89
92
 
90
93
  TITLE = "Galangal"
91
94
  CSS_PATH = "styles/app.tcss"
@@ -107,6 +110,7 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
107
110
  max_retries: int = 5,
108
111
  hidden_stages: frozenset[str] | None = None,
109
112
  stage_durations: dict[str, int] | None = None,
113
+ activity_log_path: str | Path | None = None,
110
114
  ) -> None:
111
115
  super().__init__()
112
116
  self.task_name = task_name
@@ -121,7 +125,19 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
121
125
 
122
126
  # Raw lines storage for verbose replay
123
127
  self._raw_lines: list[str] = []
124
- self._activity_entries: list[ActivityEntry] = []
128
+ self._activity_entries: deque[ActivityEntry] = deque(
129
+ maxlen=self.ACTIVITY_LOG_MAX_ENTRIES
130
+ )
131
+ self._activity_log_handle = None
132
+ if activity_log_path:
133
+ try:
134
+ log_path = Path(activity_log_path)
135
+ log_path.parent.mkdir(parents=True, exist_ok=True)
136
+ self._activity_log_handle = open(
137
+ log_path, "a", encoding="utf-8", buffering=1
138
+ )
139
+ except OSError:
140
+ self._activity_log_handle = None
125
141
 
126
142
  # Workflow control
127
143
  self._paused = False
@@ -147,7 +163,12 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
147
163
  yield ErrorPanelWidget(id="error-panel", classes="hidden")
148
164
  with Horizontal(id="content-area"):
149
165
  with VerticalScroll(id="activity-container"):
150
- yield RichLog(id="activity-log", highlight=True, markup=True)
166
+ yield RichLog(
167
+ id="activity-log",
168
+ highlight=True,
169
+ markup=True,
170
+ max_lines=self.RICH_LOG_MAX_LINES,
171
+ )
151
172
  yield FilesPanelWidget(id="files-container")
152
173
  yield CurrentActionWidget(id="current-action")
153
174
  yield Footer()
@@ -274,7 +295,11 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
274
295
  details=details,
275
296
  )
276
297
  self._activity_entries.append(entry)
277
-
298
+ if self._activity_log_handle:
299
+ try:
300
+ self._activity_log_handle.write(entry.format_export() + "\n")
301
+ except OSError:
302
+ self._activity_log_handle = None
278
303
  def _add() -> None:
279
304
  # Only show activity in compact (non-verbose) mode
280
305
  if not self.verbose:
@@ -804,7 +829,7 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
804
829
  else:
805
830
  log.write("[#b8bb26]Switched to COMPACT mode[/]")
806
831
  # Replay recent activity entries
807
- for entry in self._activity_entries[-30:]:
832
+ for entry in list(self._activity_entries)[-30:]:
808
833
  log.write(entry.format_display())
809
834
 
810
835
  def action_toggle_files(self) -> None:
@@ -827,7 +852,7 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
827
852
  @property
828
853
  def activity_entries(self) -> list[ActivityEntry]:
829
854
  """Get all activity entries for filtering or export."""
830
- return self._activity_entries.copy()
855
+ return list(self._activity_entries)
831
856
 
832
857
  def export_activity_log(self, path: str | Path) -> None:
833
858
  """
@@ -836,7 +861,7 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
836
861
  Args:
837
862
  path: File path to write the log to.
838
863
  """
839
- export_activity_log(self._activity_entries, Path(path))
864
+ export_activity_log(list(self._activity_entries), Path(path))
840
865
 
841
866
  def get_entries_by_level(self, level: ActivityLevel) -> list[ActivityEntry]:
842
867
  """Filter entries by severity level."""
@@ -846,6 +871,13 @@ class WorkflowTUIApp(WidgetAccessMixin, App[None]):
846
871
  """Filter entries by category."""
847
872
  return [e for e in self._activity_entries if e.category == category]
848
873
 
874
+ def on_shutdown(self) -> None:
875
+ if self._activity_log_handle:
876
+ try:
877
+ self._activity_log_handle.close()
878
+ except OSError:
879
+ pass
880
+
849
881
 
850
882
  class StageTUIApp(WorkflowTUIApp):
851
883
  """
@@ -189,6 +189,8 @@ class CurrentActionWidget(Static):
189
189
  class FilesPanelWidget(Static):
190
190
  """Panel showing files that have been read/written."""
191
191
 
192
+ MAX_FILES_HISTORY = 100
193
+
192
194
  def __init__(self, **kwargs):
193
195
  super().__init__(**kwargs)
194
196
  self._files: list[tuple[str, str]] = []
@@ -198,6 +200,8 @@ class FilesPanelWidget(Static):
198
200
  entry = (action, path)
199
201
  if entry not in self._files:
200
202
  self._files.append(entry)
203
+ if len(self._files) > self.MAX_FILES_HISTORY:
204
+ self._files = self._files[-self.MAX_FILES_HISTORY :]
201
205
  self.refresh()
202
206
 
203
207
  def render(self) -> Text:
@@ -239,26 +239,35 @@ class ValidationRunner:
239
239
  rollback_to="DEV",
240
240
  )
241
241
 
242
- # Check for pass/fail markers in artifacts (for AI-driven stages)
243
- if stage_config.artifact and stage_config.pass_marker:
244
- result = self._check_artifact_markers(stage_config, task_name)
242
+ # Decision file checks - these take precedence over artifact markers
243
+ # because backends (like Codex) write explicit decision files
244
+
245
+ # TEST stage: check TEST_DECISION file
246
+ if stage_lower == "test":
247
+ result = validate_stage_decision("TEST", task_name, "TEST_PLAN.md")
245
248
  if not result.success:
246
249
  return result
247
250
 
248
- # QA stage: always check QA_DECISION file first
251
+ # QA stage: check QA_DECISION file
249
252
  if stage_lower == "qa":
250
253
  result = self._check_qa_report(task_name)
251
254
  if not result.success:
252
255
  return result
253
256
 
254
- # REVIEW stage: check REVIEW_DECISION file first (for Codex/independent reviews)
257
+ # REVIEW stage: check REVIEW_DECISION file (for Codex/independent reviews)
255
258
  if stage_lower == "review":
256
259
  result = validate_stage_decision("REVIEW", task_name, "REVIEW_NOTES.md")
257
260
  if result.success or result.rollback_to:
258
- # Either passed or has a clear rollback target - return this result
261
+ # Valid decision found - use it
259
262
  return result
260
263
  # Fall through to artifact marker check if decision file missing/unclear
261
264
 
265
+ # Check for pass/fail markers in artifacts (fallback for AI-driven stages)
266
+ if stage_config.artifact and stage_config.pass_marker:
267
+ result = self._check_artifact_markers(stage_config, task_name)
268
+ if not result.success:
269
+ return result
270
+
262
271
  # Check required artifacts
263
272
  for artifact_name in stage_config.artifacts_required:
264
273
  if not artifact_exists(artifact_name, task_name):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: galangal-orchestrate
3
- Version: 0.13.0
3
+ Version: 0.13.4
4
4
  Summary: AI-driven development workflow orchestrator
5
5
  Project-URL: Homepage, https://github.com/Galangal-Media/galangal-orchestrate
6
6
  Project-URL: Repository, https://github.com/Galangal-Media/galangal-orchestrate
@@ -1,4 +1,4 @@
1
- galangal/__init__.py,sha256=XPwSFfqz5vAUsOJqKfHiscXlx3Ft0nzeKsgBvu4G808,727
1
+ galangal/__init__.py,sha256=0C6zySvsBdJkFxn1BwmA6sO2V2WSSUVcUd2nXUHZhh4,727
2
2
  galangal/__main__.py,sha256=zd1kDtEp65NKzxMYIf7eg48aXFxR3FlQgCcaVP5hGbg,114
3
3
  galangal/cli.py,sha256=cBPgMpxQD691l5Xs3GKXg-QbNS5YRnnmWhPK83DHgCY,11990
4
4
  galangal/exceptions.py,sha256=skJSzdtrB58J_d7qqIuDyDlc1DlhPF-kV8xbgGTmIwA,697
@@ -6,12 +6,12 @@ galangal/logging.py,sha256=rCgAW9Eux4-AmK2nsBKtWD-w_6jydPaG3YD9RxYvZ2g,9798
6
6
  galangal/results.py,sha256=Q6kINppudmM8fMYkLZxpqqbzGd5oClURk_vV_fYnkgM,5277
7
7
  galangal/ai/__init__.py,sha256=JewgRcJ_N9RkoyM-lC9wUOA_uS-ZeTTviLBep-0hN5E,4663
8
8
  galangal/ai/base.py,sha256=GPshLQbNwaPw2ROxiSwM805sxNDv7Vz6Oh-bWdhy9Sw,4993
9
- galangal/ai/claude.py,sha256=BWeWYRdDGtTMxrllweGU-r0kzHS9A9P8fxE1mCr5hQU,13085
10
- galangal/ai/codex.py,sha256=vQxXY2j_kPLWLHio29doJaPRG7m5LQHWVD_9k8f5pZs,13403
9
+ galangal/ai/claude.py,sha256=7la3zQL7EiCfXj3QwwgH1zkJvVArVqOBJ2sA0BS5gxU,11176
10
+ galangal/ai/codex.py,sha256=5_84f42zUkTNfn3X-yiXqo9-NyaLMERrq4I9pnHMb6g,13441
11
11
  galangal/ai/gemini.py,sha256=cqjYvu1cawC_Cn5ETAmaCn1CZBYO1HjbwpY8gjFGjBQ,1097
12
- galangal/ai/subprocess.py,sha256=2W41YdpxpP-qLMTvJh_6ySmGpyxo6KJDQ1cozm1FmRM,7803
12
+ galangal/ai/subprocess.py,sha256=rxaMyqtE_WJKmiMWCbLX69C3yzMMICnsElrCISGKnUE,9542
13
13
  galangal/commands/__init__.py,sha256=zYzp6PFc3tIVQXuufv9xZG0LNsmq5Eu9lQb3mDT-WrI,750
14
- galangal/commands/complete.py,sha256=ThAN6r76eq9rx6iYqUTdcpr9Al92KBHe1aOozxZVTuA,11698
14
+ galangal/commands/complete.py,sha256=Xff1-P2I0saUJAga2pVZoJ8VXiCBCt7cIf_XVNdqlCA,11862
15
15
  galangal/commands/github.py,sha256=W7hA7qMuTsJThcLudOFvONZr5L4EC-8rKf69EK6pCYM,12553
16
16
  galangal/commands/init.py,sha256=TWpUO-ofjHC7htPyXrl5CI6b05MzcwJm3A9GR_T6gwI,6549
17
17
  galangal/commands/init_wizard.py,sha256=v-h0fsdVPaOqCWUEzD2Qje-bEp17qwVtq6bRBZ7gFuo,24596
@@ -21,23 +21,24 @@ galangal/commands/prompts.py,sha256=xj4wv5QV1o1z0-4sLZDeO7l4AJYc26724we6jXFD1U8,
21
21
  galangal/commands/reset.py,sha256=9D1X0okJ_8IjzJXTpKn6xRBxokp4CCJSzJ3fzKNpBBE,1038
22
22
  galangal/commands/resume.py,sha256=EOc0HME1B7CeF_tFnNrB7L_8A4GgmwKQK8Rpbv-glSo,921
23
23
  galangal/commands/skip.py,sha256=dXVLx6Ek6lPBO8qPWtNXDpTVQ_C6EyU9jlF4xRSy0_w,2040
24
- galangal/commands/start.py,sha256=2gwFxoy9aNKsdDCLkZPdKR1KWWMvI8-7xXYCyDfAQ2g,21883
24
+ galangal/commands/start.py,sha256=59NFWc5xfPC6mWyEHX6GgcrRkIgf6qUViyBYs8wtx-Y,22078
25
25
  galangal/commands/status.py,sha256=OyW4OIaC6t_YksXZhRNn0rooZFR2B3UPnQRKg9MylPk,1278
26
26
  galangal/commands/switch.py,sha256=DMSZOs0052Ixwj1WbEdPOk6ymiCyJ1tn57kx2Pkk2KY,873
27
27
  galangal/config/__init__.py,sha256=o8JFiRGhfcGx0SZOn9TMEzbFQ3ZpIEHEY2lEaQZ-XMw,389
28
28
  galangal/config/defaults.py,sha256=NSopfnc1xV2u84NaCyG3k5mf_5P6RSKBRAIHIoZUDEY,5116
29
29
  galangal/config/loader.py,sha256=qBj73K7WpIn6Fx4aSZ2Qq4AIxhMfXQFvzdZdUwpug4I,4503
30
- galangal/config/schema.py,sha256=s6f7DOr-VDxBzq1hTT_AMJuIBECy5lW5UWqao6iEGqU,12183
30
+ galangal/config/schema.py,sha256=0XS3QY8lKiG2364jqJK9BBBnVWeWVyYH1ztx-ljLYq4,12679
31
31
  galangal/core/__init__.py,sha256=xwdbMC3dRVRva7nx6Z-ldvsHJpRQ0AuJ2y7BDty-KXk,752
32
32
  galangal/core/artifacts.py,sha256=ESDjRR-W6gxJw-w7M1dzvjf1-AObeAnqvMrWsDfNp1c,4101
33
- galangal/core/state.py,sha256=wASC_We2JZd07cW9wqEwDNV3e1ola5znX1iXX1cQYu4,35557
33
+ galangal/core/state.py,sha256=ASyXwd9rPjl7RCb-DCCc3etIwPdmrCul3UNN3iMgKOk,35804
34
34
  galangal/core/tasks.py,sha256=nf5S959ZxmLINuOml-KppxLrq__2BjyirZZ9LNwbDtE,14251
35
35
  galangal/core/utils.py,sha256=_x-IUKQIMfyVUrdXNEkOO_86Cu3U3RaYb6tk6cud4pI,3168
36
+ galangal/core/version_check.py,sha256=06_FTIj31AQeEpRoJSgJ4j8ZvMbHiFzDZjXWuGHRA8c,6422
36
37
  galangal/core/workflow/__init__.py,sha256=czmKbueeASxSroOtlImSE86AOikmtNkpnGCUSP7JgSw,1879
37
- galangal/core/workflow/core.py,sha256=Q0lksYjGnv_iMndGA0ZJ4kZK4qrHe5cHXc-chrEoZTc,27898
38
+ galangal/core/workflow/core.py,sha256=1iJdUxhbk6_qM-F1DYlbfqyCEzXS82358lbLbROgeaE,31872
38
39
  galangal/core/workflow/engine.py,sha256=XJKSkvRAQgeMIQLOs0Q_7dL8RnnVmfkIOezTcABIVRw,26506
39
40
  galangal/core/workflow/pause.py,sha256=N9lpNIWLW7B7hOYcAyV_UgCXMNnKtj9jDkk5x_fE-zE,1258
40
- galangal/core/workflow/tui_runner.py,sha256=GTcgLzyRvSO2lGpUwHGIXJNvG1huWuD68xY-la0F_6k,48833
41
+ galangal/core/workflow/tui_runner.py,sha256=ansGUiBzydBkOXyMnfZg-5J8DmcQsf0-92SfZjJY34U,49035
41
42
  galangal/github/__init__.py,sha256=CwSVvTQhVpwAJjyNsc7mXTKgNMFtZy7Eav7m_9ivG7o,765
42
43
  galangal/github/client.py,sha256=zY7yrFJLp9Cn079fQDz5aIj5ddB7XOfbf75sY0RBUe8,12563
43
44
  galangal/github/images.py,sha256=belPmFLClmwxjcV5dRbm8Dt3HUz8LnHZxOIHmwa5glY,9769
@@ -47,7 +48,7 @@ galangal/prompts/builder.py,sha256=ljuxp3-oBihebzI7Vr8HQLBhG6_98rYx9kObdCnehik,2
47
48
  galangal/prompts/defaults/benchmark.md,sha256=xWIinvjn04gLVSoPkeNWZqvPB33iwUP9RCpIH5bsySA,951
48
49
  galangal/prompts/defaults/contract.md,sha256=sYc8Rf5Qr7Ql_D5euzVAZDypH1NocRVAERtc37g82Zk,1085
49
50
  galangal/prompts/defaults/design.md,sha256=8Wa_saExSlWsBMG78GFSQAC2OoiNIC4Mgg-RPYoBqEE,1280
50
- galangal/prompts/defaults/dev.md,sha256=kTV_C9HTDOKl7u7LXwoL75cgx_IBKiYw23e8mpfukFs,2918
51
+ galangal/prompts/defaults/dev.md,sha256=KBpjemCKL7lr7pEYIRCNPamJfDSbWu8gZTs1ewCW5eI,3355
51
52
  galangal/prompts/defaults/docs.md,sha256=myjqZtOdthZo2go_vCa4zcylKZj44bmyVLeoVSOhnGA,3348
52
53
  galangal/prompts/defaults/migration.md,sha256=4Gu8V8b0qp9_UAdo1eabl-HLRJ2Y_ohRL1SLHNaiuHo,1615
53
54
  galangal/prompts/defaults/pm.md,sha256=Moj7jw978HeesTowxPIoJxtbLRdI1eyIdQY-wcmO370,2982
@@ -62,18 +63,18 @@ galangal/ui/__init__.py,sha256=B-VP2_Fsnc_W-sbg39pjo933wynRhFG5wJFEBohNLyk,203
62
63
  galangal/ui/console.py,sha256=dBUd9ANZvPHefnQGSFLmDBPUrJ7MyRcW1_dq_NAgl20,3859
63
64
  galangal/ui/tui/__init__.py,sha256=iCRDcdcRPoXMbVg6tsMqShZH47usbztKwU6FDly9QQY,1474
64
65
  galangal/ui/tui/adapters.py,sha256=aM9T2x5ru8-ZFYSOzGf3qAHi2bVAnR7nzQsp1Oky_iE,5920
65
- galangal/ui/tui/app.py,sha256=a9iEKsle01uTaODeM0QfkNgT4-swib-zgfdXX6MkOJY,33566
66
+ galangal/ui/tui/app.py,sha256=zwRi9YA7UwF50l2JhMfiuX22thKB5t2ddHC2Ek1bssQ,34779
66
67
  galangal/ui/tui/entry.py,sha256=Y3hKKxfyFstt6BvEdyAaKQyyvUZnccZVXK1d-RtwuIA,463
67
68
  galangal/ui/tui/mixins.py,sha256=6UBbuRCGG8r9YEF_7mgYbToE4sfTGrcU7oQHF1o6I-o,5770
68
69
  galangal/ui/tui/modals.py,sha256=8njkQYeYKZ7r8aIhde82yaW4cxBrSZGSuQEEbUb6tFA,11308
69
70
  galangal/ui/tui/types.py,sha256=I6p6FNOUZxYM3o2x3qiBeuIA1IWYOF3FQ_QaqloN1CA,3251
70
- galangal/ui/tui/widgets.py,sha256=gjtbJH-POmQov92vIJ6P-E1UfLD6ailoqPALN5dAtzY,8857
71
+ galangal/ui/tui/widgets.py,sha256=BbhE4yxW1QSwDH2QSjfb04Cq6ug1t7FyFyz-0bU0TBY,9013
71
72
  galangal/ui/tui/styles/app.tcss,sha256=qxPdBLQLrDAOoEN-v0nk7EydvkIlk0wd5oTEY4PdsWg,1236
72
73
  galangal/ui/tui/styles/modals.tcss,sha256=IxJcvs9gPuK9JDXTQgiYlcZaa_Kz3wbc_mrOOGvfgXA,3404
73
74
  galangal/validation/__init__.py,sha256=mS82eDVJXG32rMB9-fI2gMHlKXHVE8KrcvwFH8VHrmU,152
74
- galangal/validation/runner.py,sha256=51igBlz8ImEwk46XHc6jDmT2so4qakoBsKwL3bFGqmc,41359
75
- galangal_orchestrate-0.13.0.dist-info/METADATA,sha256=TW8VvTB6-iVpdwkqFKmCmXzziF-iRqc7hyGTTsA3kPs,29547
76
- galangal_orchestrate-0.13.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
77
- galangal_orchestrate-0.13.0.dist-info/entry_points.txt,sha256=ePx9z6dVezeZmGtiuGb2MfQmmXpcYfawF6HtP0WKNfk,47
78
- galangal_orchestrate-0.13.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
79
- galangal_orchestrate-0.13.0.dist-info/RECORD,,
75
+ galangal/validation/runner.py,sha256=gMGXyQtp5tP6nbogO2r1KvuVpdvF9vqLUX5AMxhbGIE,41688
76
+ galangal_orchestrate-0.13.4.dist-info/METADATA,sha256=PcAfVDYFR226nNi3XdkccO7A9SiBs0N1bW2bjFojQEY,29547
77
+ galangal_orchestrate-0.13.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
78
+ galangal_orchestrate-0.13.4.dist-info/entry_points.txt,sha256=ePx9z6dVezeZmGtiuGb2MfQmmXpcYfawF6HtP0WKNfk,47
79
+ galangal_orchestrate-0.13.4.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
80
+ galangal_orchestrate-0.13.4.dist-info/RECORD,,