deviatdd 2.5.1__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 (124) hide show
  1. deviatdd-2.5.1.dist-info/METADATA +386 -0
  2. deviatdd-2.5.1.dist-info/RECORD +124 -0
  3. deviatdd-2.5.1.dist-info/WHEEL +4 -0
  4. deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
  5. deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
  6. deviate/__init__.py +3 -0
  7. deviate/cli/__init__.py +824 -0
  8. deviate/cli/_common.py +155 -0
  9. deviate/cli/adhoc.py +183 -0
  10. deviate/cli/constitution.py +113 -0
  11. deviate/cli/feature.py +94 -0
  12. deviate/cli/init.py +485 -0
  13. deviate/cli/inspect.py +208 -0
  14. deviate/cli/macro.py +937 -0
  15. deviate/cli/meso.py +1894 -0
  16. deviate/cli/micro.py +3249 -0
  17. deviate/cli/review.py +441 -0
  18. deviate/core/__init__.py +0 -0
  19. deviate/core/_shared.py +20 -0
  20. deviate/core/agent.py +505 -0
  21. deviate/core/cache_discipline.py +65 -0
  22. deviate/core/commands.py +202 -0
  23. deviate/core/commit.py +86 -0
  24. deviate/core/complexity.py +36 -0
  25. deviate/core/constitution.py +85 -0
  26. deviate/core/contract.py +17 -0
  27. deviate/core/convention.py +147 -0
  28. deviate/core/epic.py +73 -0
  29. deviate/core/issues.py +46 -0
  30. deviate/core/prd.py +18 -0
  31. deviate/core/profile.py +33 -0
  32. deviate/core/repo.py +47 -0
  33. deviate/core/run_logger.py +50 -0
  34. deviate/core/tasks_ledger.py +69 -0
  35. deviate/core/treesitter/__init__.py +31 -0
  36. deviate/core/treesitter/analysis.py +457 -0
  37. deviate/core/treesitter/models.py +35 -0
  38. deviate/core/treesitter/parser.py +184 -0
  39. deviate/core/treesitter/queries/bash.scm +7 -0
  40. deviate/core/treesitter/queries/c_sharp.scm +11 -0
  41. deviate/core/treesitter/queries/cpp.scm +9 -0
  42. deviate/core/treesitter/queries/css.scm +4 -0
  43. deviate/core/treesitter/queries/dockerfile.scm +8 -0
  44. deviate/core/treesitter/queries/elixir.scm +6 -0
  45. deviate/core/treesitter/queries/go.scm +9 -0
  46. deviate/core/treesitter/queries/hcl.scm +3 -0
  47. deviate/core/treesitter/queries/html.scm +3 -0
  48. deviate/core/treesitter/queries/javascript.scm +12 -0
  49. deviate/core/treesitter/queries/json.scm +4 -0
  50. deviate/core/treesitter/queries/kotlin.scm +8 -0
  51. deviate/core/treesitter/queries/markdown.scm +4 -0
  52. deviate/core/treesitter/queries/python.scm +10 -0
  53. deviate/core/treesitter/queries/rust.scm +12 -0
  54. deviate/core/treesitter/queries/sql.scm +7 -0
  55. deviate/core/treesitter/queries/swift.scm +10 -0
  56. deviate/core/treesitter/queries/toml.scm +3 -0
  57. deviate/core/treesitter/queries/tsx.scm +14 -0
  58. deviate/core/treesitter/queries/typescript.scm +14 -0
  59. deviate/core/treesitter/queries/yaml.scm +4 -0
  60. deviate/core/validation.py +153 -0
  61. deviate/core/worktree.py +141 -0
  62. deviate/main.py +4 -0
  63. deviate/prompts/__init__.py +0 -0
  64. deviate/prompts/assembly.py +122 -0
  65. deviate/prompts/auto/__init__.py +1 -0
  66. deviate/prompts/auto/execute.md +62 -0
  67. deviate/prompts/auto/explore.md +103 -0
  68. deviate/prompts/auto/green.md +137 -0
  69. deviate/prompts/auto/judge.md +260 -0
  70. deviate/prompts/auto/plan.md +127 -0
  71. deviate/prompts/auto/prd.md +108 -0
  72. deviate/prompts/auto/red.md +156 -0
  73. deviate/prompts/auto/refactor.md +166 -0
  74. deviate/prompts/auto/research.md +111 -0
  75. deviate/prompts/auto/shard.md +90 -0
  76. deviate/prompts/auto/specify.md +77 -0
  77. deviate/prompts/auto/tasks.md +191 -0
  78. deviate/prompts/commands/deviate-adhoc.md +216 -0
  79. deviate/prompts/commands/deviate-architecture.md +147 -0
  80. deviate/prompts/commands/deviate-constitution.md +215 -0
  81. deviate/prompts/commands/deviate-e2e.md +264 -0
  82. deviate/prompts/commands/deviate-execute.md +223 -0
  83. deviate/prompts/commands/deviate-explore.md +253 -0
  84. deviate/prompts/commands/deviate-flows.md +226 -0
  85. deviate/prompts/commands/deviate-green.md +217 -0
  86. deviate/prompts/commands/deviate-hotfix.md +188 -0
  87. deviate/prompts/commands/deviate-init.md +170 -0
  88. deviate/prompts/commands/deviate-judge.md +193 -0
  89. deviate/prompts/commands/deviate-merge.md +221 -0
  90. deviate/prompts/commands/deviate-plan.md +158 -0
  91. deviate/prompts/commands/deviate-pr.md +144 -0
  92. deviate/prompts/commands/deviate-prd.md +216 -0
  93. deviate/prompts/commands/deviate-prune.md +260 -0
  94. deviate/prompts/commands/deviate-red.md +231 -0
  95. deviate/prompts/commands/deviate-refactor.md +211 -0
  96. deviate/prompts/commands/deviate-release.md +123 -0
  97. deviate/prompts/commands/deviate-research.md +359 -0
  98. deviate/prompts/commands/deviate-review.md +289 -0
  99. deviate/prompts/commands/deviate-shard.md +226 -0
  100. deviate/prompts/commands/deviate-tasks.md +281 -0
  101. deviate/prompts/commands/deviate-triage.md +141 -0
  102. deviate/prompts/constitution_seed.md +51 -0
  103. deviate/prompts/core/core.md +21 -0
  104. deviate/prompts/core/macro-auto.md +59 -0
  105. deviate/prompts/core/macro-command.md +54 -0
  106. deviate/prompts/core/macro-skill.md +54 -0
  107. deviate/prompts/core/meso-auto.md +63 -0
  108. deviate/prompts/core/meso-command.md +58 -0
  109. deviate/prompts/core/meso-skill.md +58 -0
  110. deviate/prompts/core/micro-auto.md +76 -0
  111. deviate/prompts/core/micro-command.md +67 -0
  112. deviate/prompts/core/micro-skill.md +67 -0
  113. deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
  114. deviate/prompts/governance/__init__.py +0 -0
  115. deviate/prompts/governance/agents_seed.md +1 -0
  116. deviate/prompts/governance/claudemd_seed.md +1 -0
  117. deviate/prompts/governance/graphite_seed.md +18 -0
  118. deviate/prompts/governance/libref_seed.md +3 -0
  119. deviate/state/__init__.py +0 -0
  120. deviate/state/config.py +257 -0
  121. deviate/state/ledger.py +407 -0
  122. deviate/ui/__init__.py +5 -0
  123. deviate/ui/monitor.py +187 -0
  124. deviate/ui/render.py +26 -0
deviate/cli/micro.py ADDED
@@ -0,0 +1,3249 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.resources
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import warnings
10
+ from collections.abc import Callable
11
+ from pathlib import Path, PurePosixPath
12
+
13
+ import typer
14
+ import yaml
15
+ from rich.console import Console
16
+
17
+ from deviate.core.agent import (
18
+ BACKEND_COMMANDS,
19
+ AgentBackend,
20
+ AgentBinaryNotFoundError,
21
+ AgentSubprocessError,
22
+ AgentTimeoutError,
23
+ EmptyOutputError,
24
+ HandoverManifest,
25
+ MalformedHandoverManifestError,
26
+ resolve_agent_to_backend,
27
+ )
28
+ from deviate.core.convention import format_commit_message
29
+ from deviate.core.profile import resolve_profile
30
+ from deviate.core.run_logger import RunLogger, get_run_logger, set_run_logger
31
+ from deviate.core.treesitter import (
32
+ detect_duplicate_blocks,
33
+ estimate_cyclomatic_complexity,
34
+ extract_changed_symbols,
35
+ extract_dead_code,
36
+ get_language_id,
37
+ incremental_parse,
38
+ )
39
+ from deviate.core.worktree import find_worktree_for_branch
40
+ from deviate.prompts.assembly import assemble_prompt
41
+ from deviate.state.config import (
42
+ AgentConfig,
43
+ PytestReportConfig,
44
+ SessionState,
45
+ resolve_graphite_config,
46
+ resolve_model_for_phase,
47
+ )
48
+ from deviate.ui.monitor import OrchestrationMonitor
49
+
50
+
51
+ from deviate.state.ledger import (
52
+ RollbackSnapshot,
53
+ TaskRecord,
54
+ append_rollback_snapshot,
55
+ append_task_transition,
56
+ )
57
+
58
+ console = Console()
59
+ _verbose: bool = False
60
+
61
+ _YAML_FENCE_OPEN_RE = re.compile(r"^```+\s*yaml", re.IGNORECASE)
62
+ _YAML_FENCE_CLOSE_RE = re.compile(r"^```+\s*$")
63
+ _MANIFEST_HEADER_RE = re.compile(r"^##\s*\[(?:HANDOVER_MANIFEST|MINIMAL_HANDOVER)\]")
64
+ _DEVIATE_MICRO_HEADER_RE = re.compile(r"^# DeviaTDD Micro")
65
+ _HANDOVER_XML_RE = re.compile(r"^</?handover_manifest>\s*$")
66
+
67
+ # Mise prefixes each task's stdout with "[<task-name>] ". Ruff and pytest
68
+ # emit "Finished in Nms" timing lines. Both are operational noise that
69
+ # the user does not need between phases — visible under --verbose.
70
+ _MISE_TASK_PREFIX_RE = re.compile(r"^\[[a-zA-Z][a-zA-Z0-9_-]*\]\s")
71
+ _MISE_TIMING_RE = re.compile(r"^Finished in \d+(?:\.\d+)?ms\s*$")
72
+
73
+
74
+ def _log(msg: str) -> None:
75
+ if _verbose:
76
+ console.print(f"[dim]{msg}[/]")
77
+
78
+
79
+ def _log_run(event: str, **kwargs: object) -> None:
80
+ logger = get_run_logger()
81
+ if logger is not None:
82
+ logger.log(event, **kwargs)
83
+
84
+
85
+ _TASK_DESC_MAX = 60
86
+
87
+
88
+ def _task_label(task: dict) -> str:
89
+ """Render ``"TSK-NNN-NN: <description>"`` for log lines.
90
+
91
+ Falls back to the bare id when description is missing or empty.
92
+ Description is truncated to ``_TASK_DESC_MAX`` chars to keep log lines
93
+ scannable.
94
+ """
95
+ tid = task.get("id", "?")
96
+ desc = task.get("description", "").strip()
97
+ if not desc:
98
+ return tid
99
+ if len(desc) > _TASK_DESC_MAX:
100
+ desc = desc[:_TASK_DESC_MAX].rstrip() + "…"
101
+ return f"{tid}: {desc}"
102
+
103
+
104
+ def _phase_already_done(ledger_path: Path, task_id: str, phase: str) -> bool:
105
+ if not ledger_path.exists():
106
+ return False
107
+ records = _read_ledger_records(ledger_path)
108
+ last_pending_idx = -1
109
+ for i, rec in enumerate(records):
110
+ if rec.get("id") == task_id and rec.get("status") == "PENDING":
111
+ last_pending_idx = i
112
+ for rec in records[last_pending_idx + 1 :]:
113
+ if rec.get("id") == task_id and rec.get("status") == phase:
114
+ return True
115
+ return False
116
+
117
+
118
+ # Typer apps for manual phase commands
119
+ red_app = typer.Typer(no_args_is_help=True)
120
+ green_app = typer.Typer(no_args_is_help=True)
121
+ judge_app = typer.Typer(no_args_is_help=True)
122
+ refactor_app = typer.Typer(no_args_is_help=True)
123
+ execute_app = typer.Typer(no_args_is_help=True)
124
+ e2e_app = typer.Typer(no_args_is_help=True)
125
+ hotfix_app = typer.Typer(no_args_is_help=True)
126
+
127
+ _LEDGER_GLOB = "specs/**/tasks.jsonl"
128
+
129
+ _SKILL_NAMES: dict[str, str | None] = {
130
+ "RED": "deviate-red",
131
+ "GREEN": "deviate-green",
132
+ "JUDGE": "deviate-judge",
133
+ "REFACTOR": "deviate-refactor",
134
+ "EXECUTE": "deviate-execute",
135
+ }
136
+
137
+
138
+ def _load_skill_content(phase_name: str) -> str | None:
139
+ skill_name = _SKILL_NAMES.get(phase_name.upper())
140
+ if not skill_name:
141
+ return None
142
+ try:
143
+ path = importlib.resources.files("deviate.prompts.commands").joinpath(
144
+ f"{skill_name}.md"
145
+ )
146
+ return path.read_text(encoding="utf-8")
147
+ except (FileNotFoundError, ModuleNotFoundError, TypeError):
148
+ fallback = Path("src/deviate/prompts/commands") / f"{skill_name}.md"
149
+ if fallback.exists():
150
+ return fallback.read_text(encoding="utf-8")
151
+ return None
152
+
153
+
154
+ def _build_agent_prompt(skill_content: str, phase: str, task: dict, root: Path) -> str:
155
+ task_context = json.dumps(
156
+ {
157
+ "phase": phase,
158
+ "task_id": task.get("id", ""),
159
+ "issue_id": task.get("issue_id", ""),
160
+ "description": task.get("description", ""),
161
+ "execution_mode": task.get("execution_mode", "TDD"),
162
+ "repo_root": str(root.resolve()),
163
+ },
164
+ indent=2,
165
+ )
166
+ return skill_content.replace("$ARGUMENTS", task_context)
167
+
168
+
169
+ _TOOL_CALL_INDICATORS = frozenset(
170
+ {
171
+ '"tool_use"',
172
+ '"tool_calls"',
173
+ "tool_use",
174
+ "tool_calls",
175
+ '"function"',
176
+ "<function_calls>",
177
+ "<invoke ",
178
+ "<tool_call",
179
+ "<use_tool",
180
+ "[Tool",
181
+ '"name": "',
182
+ '"type":"tool',
183
+ '"type": "tool',
184
+ }
185
+ )
186
+
187
+
188
+ def _is_tool_call(line: str) -> bool:
189
+ lower = line.lower().strip()
190
+ return any(ind in lower for ind in _TOOL_CALL_INDICATORS)
191
+
192
+
193
+ def _try_parse_claude_text(line: str) -> str | None:
194
+ try:
195
+ data = json.loads(line)
196
+ if isinstance(data, dict) and data.get("type") == "text":
197
+ return data.get("text", "")
198
+ except (json.JSONDecodeError, ValueError):
199
+ pass
200
+ return None
201
+
202
+
203
+ def _make_agent_output_callback(
204
+ monitor: OrchestrationMonitor | None,
205
+ task_id: str,
206
+ phase: str,
207
+ ) -> Callable[[str], None] | None:
208
+ if monitor is None:
209
+ return None
210
+
211
+ def _callback(line: str) -> None:
212
+ monitor.push_event("agent_output", task_id=task_id, phase=phase, line=line)
213
+
214
+ return _callback
215
+
216
+
217
+ def _maybe_push_event(
218
+ monitor: OrchestrationMonitor | None,
219
+ event_type: str,
220
+ **data: str | None,
221
+ ) -> None:
222
+ if monitor:
223
+ monitor.push_event(event_type, **data)
224
+
225
+
226
+ def _emit_yaml_summary(yaml_lines: list[str], c: Console) -> None:
227
+ yaml_text = "\n".join(yaml_lines)
228
+ try:
229
+ data = yaml.safe_load(yaml_text)
230
+ except Exception:
231
+ return
232
+ if not isinstance(data, dict):
233
+ return
234
+
235
+ phase = data.get("phase", "")
236
+ status = data.get("status", "")
237
+ verdict = data.get("verdict", "")
238
+
239
+ if phase:
240
+ status_str = status or verdict
241
+ if status_str:
242
+ c.print(f" [dim]{phase} \u2192 {status_str}[/]")
243
+ else:
244
+ c.print(f" [dim]{phase} complete[/]")
245
+
246
+
247
+ def _make_output_handler(c: Console, verbose: bool = False) -> Callable[[str], None]:
248
+ in_thinking = False
249
+ thinking_buf: list[str] = []
250
+ in_yaml = False
251
+ yaml_lines: list[str] = []
252
+
253
+ def handler(line: str) -> None:
254
+ nonlocal in_thinking, thinking_buf, in_yaml, yaml_lines
255
+
256
+ stripped = line.strip()
257
+ if not stripped:
258
+ return
259
+
260
+ if not verbose:
261
+ if _YAML_FENCE_OPEN_RE.match(stripped):
262
+ in_yaml = True
263
+ yaml_lines = []
264
+ return
265
+
266
+ if in_yaml:
267
+ if _YAML_FENCE_CLOSE_RE.match(stripped):
268
+ _emit_yaml_summary(yaml_lines, c)
269
+ in_yaml = False
270
+ yaml_lines = []
271
+ return
272
+ yaml_lines.append(stripped)
273
+ return
274
+
275
+ if _MANIFEST_HEADER_RE.match(stripped):
276
+ return
277
+ if _DEVIATE_MICRO_HEADER_RE.match(stripped):
278
+ return
279
+ if _HANDOVER_XML_RE.match(stripped):
280
+ return
281
+ if _MISE_TASK_PREFIX_RE.match(stripped):
282
+ return
283
+ if _MISE_TIMING_RE.match(stripped):
284
+ return
285
+
286
+ if "<thinking" in stripped.lower():
287
+ in_thinking = True
288
+ thinking_buf = [stripped]
289
+ return
290
+
291
+ if in_thinking:
292
+ if "</thinking>" in stripped.lower():
293
+ thinking_buf.append(stripped)
294
+ content = " ".join(thinking_buf)
295
+ content = (
296
+ content.replace("<thinking>", "")
297
+ .replace("</thinking>", "")
298
+ .replace("<Thinking>", "")
299
+ .replace("</Thinking>", "")
300
+ )
301
+ c.print(f"[dim]{content[:600]}[/]")
302
+ in_thinking = False
303
+ thinking_buf = []
304
+ return
305
+ thinking_buf.append(stripped)
306
+ return
307
+
308
+ claude_text = _try_parse_claude_text(stripped)
309
+ if claude_text is not None:
310
+ if claude_text.strip():
311
+ c.print(claude_text[:600], style="dim", markup=False)
312
+ return
313
+
314
+ if _is_tool_call(stripped):
315
+ c.print("[dim].[/]", end="")
316
+ sys.stdout.flush()
317
+ return
318
+
319
+ c.print(stripped[:600], style="dim", markup=False)
320
+
321
+ return handler
322
+
323
+
324
+ _PI_TOKEN_FIELD_RE = re.compile(r"^tokens\.(\w+):\s*(\d+)\s*$", re.MULTILINE)
325
+
326
+
327
+ def _extract_pi_session_stats(stdout: str) -> dict[str, int] | None:
328
+ """Parse Pi agent token usage from stdout into a dict with camelCase keys.
329
+
330
+ Recognises lines matching ``tokens.<field>: <integer>`` and returns a
331
+ dict keyed by the field name with the ``tokens.`` prefix stripped
332
+ (e.g. ``tokens.cacheRead`` → ``cacheRead``). Returns ``None`` when no
333
+ token fields are present so the caller can distinguish "absent" from
334
+ "present with zero values".
335
+ """
336
+ stats: dict[str, int] = {
337
+ match.group(1): int(match.group(2))
338
+ for match in _PI_TOKEN_FIELD_RE.finditer(stdout)
339
+ }
340
+ return stats or None
341
+
342
+
343
+ def _invoke_agent(
344
+ prompt: str,
345
+ c: Console,
346
+ backend_name: str = "opencode",
347
+ task_id: str = "",
348
+ phase: str = "",
349
+ output_callback: Callable[[str], None] | None = None,
350
+ model: str | None = None,
351
+ ) -> tuple[HandoverManifest | None, str]:
352
+ model_str = f" --model {model}" if model else ""
353
+ c.print(
354
+ f" [green]INVOKE_AGENT[/] running '{backend_name}{model_str}' for [{phase}] phase"
355
+ )
356
+ _log_run(
357
+ "INVOKE_AGENT",
358
+ task_id=task_id,
359
+ phase=phase,
360
+ backend=backend_name,
361
+ model=model or "(default)",
362
+ prompt=prompt,
363
+ )
364
+ try:
365
+ backend = AgentBackend(config=AgentConfig(backend=backend_name))
366
+ output_handler = _make_output_handler(c, verbose=_verbose)
367
+ raw_lines: list[str] = []
368
+
369
+ def collecting_handler(line: str) -> None:
370
+ raw_lines.append(line)
371
+ output_handler(line)
372
+ if output_callback:
373
+ output_callback(line)
374
+
375
+ manifest = backend.invoke(
376
+ prompt, output_callback=collecting_handler, model=model
377
+ )
378
+ c.print("")
379
+ status = getattr(manifest, "status", "?")
380
+ verdict = getattr(manifest, "verdict", "")
381
+ manifest_json = manifest.model_dump_json()
382
+ agent_result_kwargs: dict[str, object] = {
383
+ "task_id": task_id,
384
+ "phase": phase,
385
+ "status": status,
386
+ "verdict": verdict,
387
+ "manifest": manifest_json,
388
+ }
389
+ if backend_name == "pi":
390
+ agent_result_kwargs["pi_session_stats"] = _extract_pi_session_stats(
391
+ "\n".join(raw_lines)
392
+ )
393
+ _log_run("AGENT_RESULT", **agent_result_kwargs)
394
+ if raw_lines:
395
+ _log_run(
396
+ "AGENT_RAW_OUTPUT",
397
+ task_id=task_id,
398
+ phase=phase,
399
+ raw_output="\n".join(raw_lines),
400
+ )
401
+ return manifest, ""
402
+ except AgentBinaryNotFoundError:
403
+ c.print(
404
+ f" [yellow]AGENT_NOT_AVAILABLE[/] {backend_name} not found on PATH, skipping"
405
+ )
406
+ _log_run(
407
+ "AGENT_NOT_AVAILABLE", task_id=task_id, phase=phase, backend=backend_name
408
+ )
409
+ return None, ""
410
+ except AgentTimeoutError as exc:
411
+ partial_output = exc.partial_stdout or ""
412
+ c.print(f" [yellow]AGENT_ERROR[/] {exc}")
413
+ _log_run(
414
+ "AGENT_TIMEOUT",
415
+ task_id=task_id,
416
+ phase=phase,
417
+ error=str(exc),
418
+ partial_stderr=exc.partial_stderr,
419
+ partial_stdout=partial_output,
420
+ )
421
+ return None, partial_output
422
+ except (
423
+ AgentSubprocessError,
424
+ MalformedHandoverManifestError,
425
+ EmptyOutputError,
426
+ ) as exc:
427
+ c.print(f" [yellow]AGENT_ERROR[/] {exc}")
428
+ _log_run("AGENT_ERROR", task_id=task_id, phase=phase, error=str(exc))
429
+ return None, ""
430
+ except Exception as exc:
431
+ c.print(f" [yellow]AGENT_SKIP[/] {exc}")
432
+ return None, ""
433
+
434
+
435
+ _TIMEOUT_SUMMARY_PROMPT = """\
436
+ The previous agent attempt for the GREEN (implementation) phase timed out.
437
+ Partial output from that attempt is below.
438
+
439
+ Concisely summarize (under 200 words):
440
+ - What was being attempted?
441
+ - What was already completed?
442
+ - What errors or obstacles occurred?
443
+ - What should the next attempt try differently?
444
+
445
+ <partial_output>
446
+ {partial_text}
447
+ </partial_output>
448
+ """
449
+
450
+
451
+ def _summarize_timeout_context(
452
+ partial_output: str,
453
+ backend_name: str = "opencode",
454
+ ) -> str:
455
+ """Call the agent backend to summarize timeout partial output."""
456
+ truncated = partial_output[-5000:] if len(partial_output) > 5000 else partial_output
457
+ prompt = _TIMEOUT_SUMMARY_PROMPT.format(partial_text=truncated)
458
+ backend_cmd = BACKEND_COMMANDS.get(backend_name, "opencode run")
459
+ cmd = backend_cmd.split()
460
+ try:
461
+ proc = subprocess.Popen(
462
+ cmd,
463
+ stdin=subprocess.PIPE,
464
+ stdout=subprocess.PIPE,
465
+ stderr=subprocess.PIPE,
466
+ )
467
+ stdout_bytes, _ = proc.communicate(input=prompt.encode("utf-8"), timeout=30)
468
+ summary = stdout_bytes.decode("utf-8").strip()
469
+ if len(summary) > 2000:
470
+ summary = "..." + summary[-1997:]
471
+ return summary
472
+ except subprocess.TimeoutExpired:
473
+ try:
474
+ proc.kill()
475
+ proc.wait(timeout=5)
476
+ except Exception:
477
+ pass
478
+ return (
479
+ "[Previous GREEN attempt timed out \u2014 summarization also timed out. "
480
+ "Check prompts.log for partial output.]"
481
+ )
482
+ except FileNotFoundError:
483
+ return (
484
+ f"[Previous GREEN attempt timed out. Partial output (last {len(truncated)} chars):\n"
485
+ f"{truncated[-500:]}]"
486
+ )
487
+
488
+
489
+ def _git_env() -> dict[str, str]:
490
+ return {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
491
+
492
+
493
+ def _resolve_workspace_root() -> Path:
494
+ """Resolve workspace root from current branch → worktree path.
495
+
496
+ If already inside a git worktree (``.git`` is a file), returns CWD.
497
+ Otherwise queries the current branch and looks up the matching
498
+ worktree path. Falls back to ``Path.cwd()`` when neither applies.
499
+ """
500
+ root = Path.cwd()
501
+ git_path = root / ".git"
502
+ if git_path.exists() and not git_path.is_dir():
503
+ return root
504
+ try:
505
+ branch = subprocess.run(
506
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
507
+ cwd=root,
508
+ capture_output=True,
509
+ text=True,
510
+ check=True,
511
+ ).stdout.strip()
512
+ if branch and branch != "HEAD":
513
+ wt = find_worktree_for_branch(branch, repo=root)
514
+ if wt is not None:
515
+ return wt
516
+ except (subprocess.CalledProcessError, OSError):
517
+ pass
518
+ return root
519
+
520
+
521
+ def _read_ledger_records(ledger_file: Path) -> list[dict]:
522
+ records: list[dict] = []
523
+ try:
524
+ with open(ledger_file, encoding="utf-8") as f:
525
+ for line in f:
526
+ stripped = line.strip()
527
+ if not stripped:
528
+ continue
529
+ try:
530
+ records.append(json.loads(stripped))
531
+ except json.JSONDecodeError:
532
+ continue
533
+ except FileNotFoundError:
534
+ pass
535
+ return records
536
+
537
+
538
+ def _resolve_issue_number(task_id: str) -> str | None:
539
+ m = re.match(r"^TSK-(\d{3})-\d{2}$", task_id)
540
+ if m:
541
+ return m.group(1)
542
+ return None
543
+
544
+
545
+ def _find_task_record(root: Path, task_id: str) -> tuple[dict, Path] | None:
546
+ """Look up the latest (current) record by its TSK-NNN-NN ID."""
547
+ for rec, ledger_file in _collect_latest_task_records(root):
548
+ if rec.get("id") == task_id:
549
+ return rec, ledger_file
550
+ return None
551
+
552
+
553
+ _TERMINAL_STATUSES = {"COMPLETED", "FAILED", "REFACTOR"}
554
+
555
+
556
+ def _collect_latest_task_records(root: Path) -> list[tuple[dict, Path]]:
557
+ """Return the latest record per task ID across all ledger files.
558
+
559
+ Because the ledger is append-only (chronological within each file,
560
+ files sorted lexicographically), the last seen record for each task
561
+ ID represents its current status.
562
+ """
563
+ latest: dict[str, dict] = {}
564
+ ledger_of: dict[str, Path] = {}
565
+ for ledger_file in sorted(root.glob(_LEDGER_GLOB)):
566
+ for rec in _read_ledger_records(ledger_file):
567
+ tid = rec.get("id")
568
+ if tid:
569
+ latest[tid] = rec
570
+ ledger_of[tid] = ledger_file
571
+ return [(latest[tid], ledger_of[tid]) for tid in latest]
572
+
573
+
574
+ _BRANCH_SLUG_RE = re.compile(r"^feat/([^/]+)/([^/]+(?:/[^/]+)*)$")
575
+ _TASK_LINE_RE = re.compile(r"^\s*-\s+(?:\[(x| )\]\s+)?(TSK-\d{3}-\d{2}):\s*(.*)")
576
+ _MODE_LINE_RE = re.compile(r"^\s*-\s+\*\*Mode\*\*:\s*(\S+)")
577
+
578
+
579
+ def _find_all_pending_tasks(
580
+ root: Path, issue_id: str | None = None
581
+ ) -> list[tuple[dict, Path]]:
582
+ _log(f"find_all_pending_tasks: issue_id={issue_id}, root={root}")
583
+
584
+ latest_by_issue: dict[tuple[str, str], dict] = {}
585
+ ledger_of_by_issue: dict[tuple[str, str], Path] = {}
586
+ for rec, ledger_file in _collect_latest_task_records(root):
587
+ tid = rec["id"]
588
+ rec_issue = rec.get("issue_id", "")
589
+ if not rec_issue:
590
+ continue
591
+ if issue_id is not None and rec_issue != issue_id:
592
+ _log(f" skipping {tid} from issue {rec_issue} (expected {issue_id})")
593
+ continue
594
+ key = (rec_issue, tid)
595
+ latest_by_issue[key] = rec
596
+ ledger_of_by_issue[key] = ledger_file
597
+ _log(
598
+ f" ledger record: {tid} ({rec_issue})"
599
+ f" → {rec.get('status')} ({ledger_file.name})"
600
+ )
601
+
602
+ seen: set[str] = set()
603
+ results: list[tuple[dict, Path]] = []
604
+
605
+ def _process_one_tasks_md(md_path: Path, md_issue_id: str) -> None:
606
+ fallback = md_path.parent / "tasks.jsonl"
607
+ content_lines = md_path.read_text(encoding="utf-8").splitlines()
608
+ for i, line in enumerate(content_lines):
609
+ m = _TASK_LINE_RE.match(line)
610
+ if m is None:
611
+ continue
612
+ tid = m.group(2)
613
+ checkbox = m.group(1)
614
+ _log(f" tasks.md task: {tid} (issue={md_issue_id})")
615
+ seen.add(tid)
616
+ key = (md_issue_id, tid)
617
+ rec = latest_by_issue.get(key)
618
+ if rec is not None:
619
+ if rec.get("status") in _TERMINAL_STATUSES:
620
+ _log(f" → terminal ({rec.get('status')}), skipping")
621
+ continue
622
+ _log(f" → status={rec.get('status')}, including")
623
+ results.append((rec, ledger_of_by_issue.get(key, fallback)))
624
+ continue
625
+ if checkbox and checkbox.lower() == "x":
626
+ _log(" → checked [x] in tasks.md, skipping")
627
+ continue
628
+ mode = "TDD"
629
+ for j in range(i + 1, min(i + 10, len(content_lines))):
630
+ mode_m = _MODE_LINE_RE.match(content_lines[j])
631
+ if mode_m:
632
+ mode = mode_m.group(1)
633
+ break
634
+ _log(f" → no ledger entry, mode={mode}")
635
+ results.append(
636
+ (
637
+ {
638
+ "id": tid,
639
+ "issue_id": md_issue_id,
640
+ "description": m.group(3).strip(),
641
+ "status": "PENDING",
642
+ "execution_mode": mode,
643
+ },
644
+ fallback,
645
+ )
646
+ )
647
+
648
+ if issue_id is not None:
649
+ tasks_md = _find_tasks_md_for_issue(root, issue_id)
650
+ _log(f" tasks_md: {tasks_md}")
651
+ if tasks_md is not None:
652
+ _process_one_tasks_md(tasks_md, issue_id)
653
+ else:
654
+ for tasks_md in sorted(root.glob("specs/**/tasks.md")):
655
+ md_issue_id = _resolve_md_issue_id(tasks_md)
656
+ _log(f" tasks_md: {tasks_md} → issue_id={md_issue_id}")
657
+ _process_one_tasks_md(tasks_md, md_issue_id)
658
+
659
+ for (rec_issue, tid), rec in latest_by_issue.items():
660
+ if tid in seen:
661
+ continue
662
+ if issue_id is not None and rec_issue != issue_id:
663
+ continue
664
+ if rec.get("status") not in _TERMINAL_STATUSES:
665
+ _log(
666
+ f" orphan ledger task: {tid} ({rec_issue}"
667
+ f", {rec.get('status')}), including"
668
+ )
669
+ results.append((rec, ledger_of_by_issue[(rec_issue, tid)]))
670
+ else:
671
+ _log(
672
+ f" orphan ledger task: {tid} ({rec_issue}"
673
+ f", {rec.get('status')}), skipping"
674
+ )
675
+
676
+ _log(f" total pending: {len(results)}")
677
+ return results
678
+
679
+
680
+ def _resolve_issue_source_file(root: Path, issue_id: str) -> str | None:
681
+ """Resolve source_file from specs/issues.jsonl for a given issue_id."""
682
+ ledger_path = root / "specs" / "issues.jsonl"
683
+ if not ledger_path.exists():
684
+ return None
685
+ for data in _read_ledger_records(ledger_path):
686
+ if data.get("issue_id") == issue_id:
687
+ return data.get("source_file")
688
+ return None
689
+
690
+
691
+ def _find_tasks_md_for_issue(root: Path, issue_id: str) -> Path | None:
692
+ """Find tasks.md for a given issue_id by reading issues.jsonl."""
693
+ source_file = _resolve_issue_source_file(root, issue_id)
694
+ if not source_file:
695
+ return None
696
+ parts = PurePosixPath(source_file)
697
+ if len(parts.parts) < 3:
698
+ return None
699
+ epic = parts.parent.parent.name
700
+ slug = parts.stem
701
+ tasks_md = root / "specs" / epic / slug / "tasks.md"
702
+ if tasks_md.exists():
703
+ return tasks_md
704
+ return None
705
+
706
+
707
+ def _resolve_md_issue_id(md_path: Path) -> str:
708
+ """Derive issue_id from a tasks.md's sibling tasks.jsonl."""
709
+ ledger_path = md_path.parent / "tasks.jsonl"
710
+ if not ledger_path.exists():
711
+ return ""
712
+ for rec in _read_ledger_records(ledger_path):
713
+ iid = rec.get("issue_id", "")
714
+ if iid:
715
+ return iid
716
+ return ""
717
+
718
+
719
+ def _resolve_issue_id_from_branch(root: Path) -> str | None:
720
+ """Derive issue_id from the current git branch via issues.jsonl."""
721
+ try:
722
+ branch = subprocess.run(
723
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
724
+ cwd=root,
725
+ capture_output=True,
726
+ text=True,
727
+ check=True,
728
+ ).stdout.strip()
729
+ except (subprocess.CalledProcessError, OSError):
730
+ return None
731
+ m = _BRANCH_SLUG_RE.match(branch)
732
+ if not m:
733
+ return None
734
+ bucket = m.group(1)
735
+ slug = m.group(2)
736
+ target = f"{bucket}/issues/{slug}.md"
737
+ ledger_path = root / "specs" / "issues.jsonl"
738
+ if not ledger_path.exists():
739
+ return None
740
+ for rec in _read_ledger_records(ledger_path):
741
+ src = rec.get("source_file", "")
742
+ if target in src:
743
+ return rec.get("issue_id")
744
+ return None
745
+
746
+
747
+ def _append_status_transition(
748
+ task_data: dict, new_status: str, ledger_path: Path
749
+ ) -> None:
750
+ record = TaskRecord(
751
+ id=task_data["id"],
752
+ issue_id=task_data.get("issue_id", ""),
753
+ description=task_data.get("description", ""),
754
+ status=new_status,
755
+ execution_mode=task_data.get("execution_mode", "TDD"),
756
+ )
757
+ append_task_transition(record, ledger_path)
758
+
759
+
760
+ def _resolve_task_context(task_id: str | None, root: Path) -> tuple[dict, Path] | None:
761
+ if task_id is not None:
762
+ if not re.match(r"^TSK-\d{3}-\d{2}$", task_id):
763
+ console.print(
764
+ f"[red]TASK_NOT_FOUND[/] Unrecognised task ID format: {task_id}"
765
+ )
766
+ raise typer.Exit(code=1)
767
+ result = _find_task_record(root, task_id)
768
+ if result is None:
769
+ console.print(f"[red]TASK_NOT_FOUND[/] No task matching {task_id}")
770
+ raise typer.Exit(code=1)
771
+ return result
772
+
773
+ dot_dir = root / ".deviate"
774
+ session_path = dot_dir / "session.json"
775
+ session = (
776
+ SessionState.load(session_path) if session_path.exists() else SessionState()
777
+ )
778
+
779
+ pending = _find_all_pending_tasks(root, issue_id=session.active_issue_id)
780
+ if not pending:
781
+ console.print("[red]NO_PENDING_TASKS[/]")
782
+ raise typer.Exit(code=1)
783
+ return pending[0]
784
+
785
+
786
+ def _resolve_latest_task(
787
+ root: Path, issue_id: str, status: str
788
+ ) -> tuple[dict, Path] | None:
789
+ """Return the most recent task record with *issue_id* and *status*."""
790
+ latest: tuple[dict, Path] | None = None
791
+ for ledger_file in sorted(root.glob(_LEDGER_GLOB)):
792
+ for rec in _read_ledger_records(ledger_file):
793
+ if rec.get("issue_id") == issue_id and rec.get("status") == status:
794
+ latest = (rec, ledger_file)
795
+ return latest
796
+
797
+
798
+ def _resolve_first_pending(root: Path, issue_id: str) -> tuple[dict, Path] | None:
799
+ """Return the first task whose latest status is PENDING for *issue_id*."""
800
+ for rec, ledger_file in _find_all_pending_tasks(root, issue_id=issue_id):
801
+ if rec.get("status") == "PENDING":
802
+ return (rec, ledger_file)
803
+ return None
804
+
805
+
806
+ def _build_scope(issue_id: str, task_id: str) -> str:
807
+ """Return the task ID as scope (already TSK-NNN-NN format)."""
808
+ if task_id and task_id != "?":
809
+ return task_id
810
+ return issue_id
811
+
812
+
813
+ def _build_auto_prompt(phase: str, task: dict, root: Path) -> str:
814
+ """Build a prompt from auto templates with context injected."""
815
+ issue_id = task.get("issue_id", "")
816
+ task_id = task.get("id", "")
817
+ source_file = _resolve_issue_source_file(root, issue_id) if issue_id else None
818
+
819
+ spec_content = _resolve_spec_md(root, task)
820
+
821
+ feature_slug = ""
822
+ issue_slug = ""
823
+ if source_file:
824
+ parts = PurePosixPath(source_file)
825
+ feature_slug = parts.parent.parent.name if len(parts.parts) >= 3 else ""
826
+ issue_slug = parts.stem
827
+
828
+ data_model_content = ""
829
+ if feature_slug and issue_slug:
830
+ dm_path = root / "specs" / feature_slug / issue_slug / "data-model.md"
831
+ if dm_path.exists():
832
+ data_model_content = dm_path.read_text(encoding="utf-8")
833
+
834
+ prd_content = ""
835
+ if feature_slug:
836
+ prd_path = root / "specs" / feature_slug / "prd.md"
837
+ if prd_path.exists():
838
+ prd_content = prd_path.read_text(encoding="utf-8")
839
+
840
+ task_content = json.dumps(task, indent=2)
841
+ test_command = task.get("verification", "")
842
+ lint_command = _resolve_lint_command(root)
843
+ verification_command = task.get("verification", "")
844
+ verification_binary = task.get("verification", "")
845
+
846
+ const_path = root / "specs" / "constitution.md"
847
+
848
+ context: dict[str, str] = {
849
+ "task_content": task_content,
850
+ "spec_content": spec_content,
851
+ "data_model_content": data_model_content,
852
+ "prd_content": prd_content,
853
+ "task_id": task_id,
854
+ "issue_id": issue_id,
855
+ "feature_slug": feature_slug,
856
+ "test_command": test_command,
857
+ "lint_command": lint_command,
858
+ "verification_command": verification_command,
859
+ "verification_binary": verification_binary,
860
+ "next_phase": "",
861
+ }
862
+ return assemble_prompt(
863
+ template_name=phase, context=context, constitution_path=const_path
864
+ )
865
+
866
+
867
+ def _resolve_lint_command(root: Path) -> str:
868
+ const_path = root / "specs" / "constitution.md"
869
+ if const_path.exists():
870
+ from deviate.core.constitution import extract_commands
871
+
872
+ cmds = extract_commands(const_path)
873
+ return cmds.get("lint_command", "")
874
+ return ""
875
+
876
+
877
+ def _run_red_phase(
878
+ task: dict,
879
+ ledger_path: Path,
880
+ session: SessionState,
881
+ session_path: Path,
882
+ c: Console,
883
+ agent: str | None = None,
884
+ monitor: OrchestrationMonitor | None = None,
885
+ ) -> SessionState:
886
+ tid = task.get("id", "?")
887
+ if _phase_already_done(ledger_path, task.get("id", ""), "RED"):
888
+ c.print(f" [dim]RED already done for {_task_label(task)}, skipping[/]")
889
+ return session
890
+ _log_run("PHASE_START", task_id=tid, phase="RED")
891
+ c.print(f" [bold blue]RED →[/] {_task_label(task)}")
892
+
893
+ backend = agent or "opencode"
894
+ root = Path.cwd()
895
+ prompt = _build_auto_prompt("red", task, root)
896
+ agent_output_callback = _make_agent_output_callback(monitor, tid, "RED")
897
+ red_model = resolve_model_for_phase("RED", root)
898
+ manifest, _ = _invoke_agent(
899
+ prompt,
900
+ c,
901
+ backend_name=backend,
902
+ task_id=tid,
903
+ phase="RED",
904
+ output_callback=agent_output_callback,
905
+ model=red_model,
906
+ )
907
+ if manifest is None:
908
+ raise PhaseFailedError(
909
+ f"RED phase agent error for {tid}: agent returned no manifest"
910
+ )
911
+ if manifest.status.upper() in ("FAILURE", "ERROR"):
912
+ raise PhaseFailedError(
913
+ f"RED phase failed for {tid}: {manifest.rationale or 'unknown'}"
914
+ )
915
+
916
+ issue_id = task.get("issue_id", "")
917
+ scope = _build_scope(issue_id, tid)
918
+
919
+ test_files = _find_test_files(root)
920
+ if test_files:
921
+ _run_test_cmd(root)
922
+
923
+ _run_format_cmd(root)
924
+
925
+ try:
926
+ record = TaskRecord.model_validate(task)
927
+ record.status = "RED"
928
+ append_task_transition(record, ledger_path)
929
+ except Exception as e:
930
+ raise PhaseFailedError(f"RED phase ledger update failed for {tid}: {e}")
931
+
932
+ session = session.force_transition_to("RED")
933
+ session.save(session_path)
934
+
935
+ _commit_phase(
936
+ f"test({scope}): RED phase - failing test",
937
+ root,
938
+ no_verify=True,
939
+ phase="red",
940
+ )
941
+
942
+ head_sha = subprocess.run(
943
+ ["git", "rev-parse", "HEAD"],
944
+ cwd=root,
945
+ capture_output=True,
946
+ text=True,
947
+ env=_git_env(),
948
+ ).stdout.strip()
949
+ session.red_commit_sha = head_sha
950
+ session.save(session_path)
951
+
952
+ _verify_clean_worktree(root, "RED", tid)
953
+ return session
954
+
955
+
956
+ def _run_green_phase(
957
+ task: dict,
958
+ ledger_path: Path,
959
+ session: SessionState,
960
+ session_path: Path,
961
+ c: Console,
962
+ agent: str | None = None,
963
+ monitor: OrchestrationMonitor | None = None,
964
+ ) -> SessionState:
965
+ tid = task.get("id", "?")
966
+ if _phase_already_done(ledger_path, task.get("id", ""), "GREEN"):
967
+ if not session.train_feedback:
968
+ c.print(f" [dim]GREEN already done for {_task_label(task)}, skipping[/]")
969
+ return session
970
+ c.print(
971
+ f" [dim]GREEN already done for {_task_label(task)}"
972
+ f" but train_feedback present — re-running[/]"
973
+ )
974
+ _log_run("PHASE_START", task_id=tid, phase="GREEN")
975
+ c.print(f" [bold green]GREEN →[/] {_task_label(task)}")
976
+
977
+ backend = agent or "opencode"
978
+ root = Path.cwd()
979
+ prompt = _build_auto_prompt("green", task, root)
980
+ if session.train_feedback:
981
+ prompt += f"\n\n<train_feedback>\n{session.train_feedback}\n</train_feedback>\n"
982
+ agent_output_callback = _make_agent_output_callback(monitor, tid, "GREEN")
983
+ green_model = resolve_model_for_phase("GREEN", root)
984
+ manifest, timeout_ctx = _invoke_agent(
985
+ prompt,
986
+ c,
987
+ backend_name=backend,
988
+ task_id=tid,
989
+ phase="GREEN",
990
+ output_callback=agent_output_callback,
991
+ model=green_model,
992
+ )
993
+ if manifest is None and timeout_ctx:
994
+ c.print(
995
+ " [yellow]TIMEOUT[/] GREEN agent timed out \u2014 summarizing context for retry"
996
+ )
997
+ summary = _summarize_timeout_context(timeout_ctx, backend_name=backend)
998
+ session.train_feedback = summary
999
+ session.save(session_path)
1000
+ raise PhaseFailedError(f"GREEN phase agent timed out for {tid}")
1001
+ if manifest is None:
1002
+ raise PhaseFailedError(
1003
+ f"GREEN phase agent error for {tid}: agent returned no manifest"
1004
+ )
1005
+ if manifest.status.upper() in ("FAILURE", "ERROR", "FAIL"):
1006
+ raise PhaseFailedError(
1007
+ f"GREEN phase failed for {tid}: {manifest.rationale or 'unknown'}"
1008
+ )
1009
+
1010
+ session = session.force_transition_to("GREEN")
1011
+ session.train_feedback = ""
1012
+ session.judge_rejected = False
1013
+ session.save(session_path)
1014
+
1015
+ issue_id = task.get("issue_id", "")
1016
+ scope = _build_scope(issue_id, tid)
1017
+
1018
+ test_result = _run_test_cmd(root)
1019
+ if test_result.returncode != 0:
1020
+ failure_output = test_result.stdout or ""
1021
+ if test_result.stderr:
1022
+ failure_output += "\n--- stderr ---\n" + test_result.stderr
1023
+ c.print(
1024
+ f" [yellow]TEST_FAILURE[/] {tid} \u2014 keeping implementation for JUDGE assessment"
1025
+ )
1026
+ session.train_feedback = (
1027
+ "The test suite failed after GREEN implementation.\n\n"
1028
+ f"<test_output>\n{failure_output}\n</test_output>"
1029
+ )
1030
+ session.save(session_path)
1031
+ return session
1032
+
1033
+ _run_format_cmd(root)
1034
+
1035
+ try:
1036
+ record = TaskRecord.model_validate(task)
1037
+ record.status = "GREEN"
1038
+ append_task_transition(record, ledger_path)
1039
+ except Exception as e:
1040
+ raise PhaseFailedError(f"GREEN phase ledger update failed for {tid}: {e}")
1041
+
1042
+ _commit_phase(
1043
+ f"feat({scope}): GREEN phase - implementation",
1044
+ root,
1045
+ no_verify=True,
1046
+ phase="green",
1047
+ )
1048
+
1049
+ try:
1050
+ _verify_clean_worktree(root, "GREEN", tid)
1051
+ except PhaseFailedError as e:
1052
+ c.print(f" [yellow]CLEAN_WORKTREE_FAILED[/] {e}")
1053
+ # Try to commit leftover files instead of destroying the GREEN commit
1054
+ issue_id = task.get("issue_id", "")
1055
+ scope = _build_scope(issue_id, tid)
1056
+ residual_committed = _commit_phase(
1057
+ f"feat({scope}): GREEN phase - residual files",
1058
+ root,
1059
+ no_verify=True,
1060
+ phase="green",
1061
+ )
1062
+ if residual_committed:
1063
+ c.print(f" [green]Residual files committed[/] for {tid}")
1064
+ else:
1065
+ c.print(
1066
+ f" [yellow]WARNING[/] {tid} has uncommitted files after GREEN — "
1067
+ "leaving for JUDGE assessment"
1068
+ )
1069
+ session.train_feedback = str(e)
1070
+ session.save(session_path)
1071
+ return session
1072
+
1073
+
1074
+ def _resolve_spec_md(root: Path, task: dict) -> str:
1075
+ """Read spec-enriched issue file content for *task*.
1076
+
1077
+ The issue file IS the spec — spec sections are embedded in the issue
1078
+ file markdown. No separate ``spec.md`` exists.
1079
+ """
1080
+ issue_id = task.get("issue_id", "")
1081
+ if not issue_id:
1082
+ return ""
1083
+ source_file = _resolve_issue_source_file(root, issue_id)
1084
+ if not source_file:
1085
+ return ""
1086
+ issue_path = root / source_file
1087
+ if issue_path.exists():
1088
+ return issue_path.read_text(encoding="utf-8")
1089
+ return ""
1090
+
1091
+
1092
+ def _resolve_tasks_md(root: Path, task: dict) -> Path | None:
1093
+ issue_id = task.get("issue_id", "")
1094
+ if not issue_id:
1095
+ return None
1096
+ return _find_tasks_md_for_issue(root, issue_id)
1097
+
1098
+
1099
+ def _append_judge_feedback(tasks_md: Path, task_id: str, feedback: str) -> int | None:
1100
+ """Append judge feedback under the matching task line in tasks.md.
1101
+
1102
+ Returns the number of feedback lines inserted, or ``None`` if no
1103
+ matching task line was found (so callers can surface a "no tasks.md
1104
+ update" log line and skip the bookkeeping commit).
1105
+ """
1106
+ content = tasks_md.read_text(encoding="utf-8")
1107
+ lines = content.splitlines()
1108
+ new_lines: list[str] = []
1109
+ inserted = False
1110
+ feedback_lines = feedback.strip().splitlines() or [""]
1111
+ for line in lines:
1112
+ new_lines.append(line)
1113
+ if not inserted and task_id in line and line.strip().startswith("-"):
1114
+ indent = " "
1115
+ for fb_line in feedback_lines:
1116
+ new_lines.append(f"{indent}- **Judge Feedback**: {fb_line}")
1117
+ indent = " "
1118
+ inserted = True
1119
+ if inserted:
1120
+ tasks_md.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
1121
+ return len(feedback_lines)
1122
+ return None
1123
+
1124
+
1125
+ def _resolve_red_boundary_sha(root: Path) -> str:
1126
+ session_path = root / ".deviate" / "session.json"
1127
+ if session_path.exists():
1128
+ session = SessionState.load(session_path)
1129
+ if session.red_commit_sha:
1130
+ return session.red_commit_sha
1131
+ _log("No RED commit SHA in session — falling back to HEAD~1 as boundary")
1132
+ parent = subprocess.run(
1133
+ ["git", "rev-parse", "HEAD~1"],
1134
+ cwd=root,
1135
+ capture_output=True,
1136
+ text=True,
1137
+ env=_git_env(),
1138
+ ).stdout.strip()
1139
+ if parent:
1140
+ return parent
1141
+ _log("HEAD~1 is empty — using root commit as last resort")
1142
+ root_sha = subprocess.run(
1143
+ ["git", "rev-list", "--max-parents=0", "HEAD"],
1144
+ cwd=root,
1145
+ capture_output=True,
1146
+ text=True,
1147
+ env=_git_env(),
1148
+ ).stdout.strip()
1149
+ return root_sha
1150
+
1151
+
1152
+ def _execute_rollback(root: Path, reason: str, phase: str = "JUDGE") -> str:
1153
+ branch = subprocess.run(
1154
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1155
+ cwd=root,
1156
+ capture_output=True,
1157
+ text=True,
1158
+ env=_git_env(),
1159
+ ).stdout.strip()
1160
+ red_sha = _resolve_red_boundary_sha(root)
1161
+ commit_sha = subprocess.run(
1162
+ ["git", "rev-parse", "HEAD"],
1163
+ cwd=root,
1164
+ capture_output=True,
1165
+ text=True,
1166
+ env=_git_env(),
1167
+ ).stdout.strip()
1168
+ snapshot = RollbackSnapshot(
1169
+ phase=phase,
1170
+ branch=branch,
1171
+ commit_sha=commit_sha,
1172
+ red_sha=red_sha,
1173
+ reason=reason[:500],
1174
+ )
1175
+ append_rollback_snapshot(snapshot, root / ".deviate")
1176
+
1177
+ # Discard any uncommitted session state
1178
+ subprocess.run(
1179
+ ["git", "checkout", "--quiet", "--", ".deviate/"],
1180
+ cwd=root,
1181
+ capture_output=True,
1182
+ env=_git_env(),
1183
+ )
1184
+
1185
+ # Reset to red_sha — discards ALL commits made during GREEN (agent
1186
+ # commit, orchestrator commit, residual commit) preserving only RED
1187
+ # and any previous judge feedback commits.
1188
+ #
1189
+ # If GREEN never committed (tests failed, early return),
1190
+ # HEAD == red_sha and the reset is a no-op on history.
1191
+ subprocess.run(
1192
+ ["git", "reset", "--hard", red_sha],
1193
+ cwd=root,
1194
+ capture_output=True,
1195
+ env=_git_env(),
1196
+ )
1197
+ return red_sha
1198
+
1199
+
1200
+ def _parse_diff_filepaths(diff_text: str) -> list[str]:
1201
+ paths: list[str] = []
1202
+ for line in diff_text.splitlines():
1203
+ if line.startswith("diff --git"):
1204
+ parts = line.split()
1205
+ if len(parts) >= 4:
1206
+ b_path = parts[-1].lstrip("b/")
1207
+ paths.append(b_path)
1208
+ return paths
1209
+
1210
+
1211
+ def _build_structured_diff_section(diff_text: str) -> str:
1212
+ rows: list[str] = []
1213
+ if diff_text.strip():
1214
+ for fp in _parse_diff_filepaths(diff_text):
1215
+ for sc in extract_changed_symbols(diff_text, fp):
1216
+ lines = (
1217
+ f"{sc.start_line}-{sc.end_line}"
1218
+ if sc.start_line or sc.end_line
1219
+ else "-"
1220
+ )
1221
+ old_lines = (
1222
+ f"{sc.old_start_line}-{sc.old_end_line}"
1223
+ if sc.old_start_line or sc.old_end_line
1224
+ else "-"
1225
+ )
1226
+ sig = sc.new_signature or sc.old_signature or "-"
1227
+ sz = (
1228
+ f"{sc.old_line_count}→{sc.new_line_count}"
1229
+ if sc.old_line_count or sc.new_line_count
1230
+ else "-"
1231
+ )
1232
+ rows.append(
1233
+ f"| {sc.language} | {sc.kind} | {sc.name} | {sc.change} "
1234
+ f"| {lines} | {old_lines} | {sz} | `{sig[:60]}` |"
1235
+ )
1236
+ if not rows:
1237
+ return ""
1238
+ table = (
1239
+ "## Structured Diff Summary\n\n"
1240
+ "| Language | Kind | Name | Change | Lines (new) | Lines (old) | Body Δ | Signature |\n"
1241
+ "| --- | --- | --- | --- | --- | --- | --- | --- |\n"
1242
+ )
1243
+ table += "\n".join(rows)
1244
+ return "\n\n" + table
1245
+
1246
+
1247
+ def _format_violations_as_feedback(
1248
+ violations: list[dict[str, object]],
1249
+ ) -> str:
1250
+ """Render a structured ``violations`` list as readable feedback text.
1251
+
1252
+ Both judge schemas are supported:
1253
+ - Auto template: category / file / detail / severity / recommendation
1254
+ - Manual skill: file / detail / severity / requirement
1255
+
1256
+ Returns an empty string when the list is empty so the caller can chain
1257
+ it as another fallback in the feedback-resolution cascade.
1258
+ """
1259
+ if not violations:
1260
+ return ""
1261
+ lines: list[str] = []
1262
+ for i, v in enumerate(violations, start=1):
1263
+ category = v.get("category", "")
1264
+ file = v.get("file", "")
1265
+ detail = v.get("detail", "")
1266
+ severity = v.get("severity", "")
1267
+ requirement = v.get("requirement", "")
1268
+ recommendation = v.get("recommendation", "")
1269
+ parts: list[str] = []
1270
+ if category:
1271
+ parts.append(f"[{category}]")
1272
+ if severity:
1273
+ parts.append(f"({severity})")
1274
+ if file:
1275
+ parts.append(f"file: {file}")
1276
+ if requirement:
1277
+ parts.append(f"req: {requirement}")
1278
+ head = " ".join(parts) if parts else f"violation {i}"
1279
+ body = detail or ""
1280
+ if recommendation:
1281
+ body = (body + " " if body else "") + f"Recommendation: {recommendation}"
1282
+ lines.append(f"- {head}: {body}".rstrip())
1283
+ return "\n".join(lines)
1284
+
1285
+
1286
+ def _run_judge_phase(
1287
+ task: dict,
1288
+ ledger_path: Path,
1289
+ session: SessionState,
1290
+ session_path: Path,
1291
+ c: Console,
1292
+ agent: str | None = None,
1293
+ monitor: OrchestrationMonitor | None = None,
1294
+ ) -> SessionState:
1295
+ tid = task.get("id", "?")
1296
+ _log_run("PHASE_START", task_id=tid, phase="JUDGE")
1297
+ c.print(f" [bold magenta]JUDGE →[/] {_task_label(task)}")
1298
+
1299
+ backend = agent or "opencode"
1300
+ root = Path.cwd()
1301
+
1302
+ # Span the RED→GREEN diff: use RED's parent as the baseline so the
1303
+ # judge sees both the failing tests (committed in RED) and the
1304
+ # implementation (committed in GREEN). Without the parent anchor,
1305
+ # `git diff red_sha..HEAD` would collapse to GREEN only — the tests
1306
+ # already exist in `red_sha` and disappear from the diff — and the
1307
+ # judge would (correctly, given its input) flag the missing tests.
1308
+ # The fallback (no RED in this session) keeps the prior single-commit
1309
+ # behavior so the diff still matches the GREEN/EXECUTE-only commit.
1310
+ if session.red_commit_sha:
1311
+ diff_base = f"{session.red_commit_sha}^"
1312
+ else:
1313
+ diff_base = "HEAD~1"
1314
+ diff = subprocess.run(
1315
+ ["git", "diff", f"{diff_base}..HEAD"],
1316
+ cwd=root,
1317
+ capture_output=True,
1318
+ text=True,
1319
+ env=_git_env(),
1320
+ ).stdout
1321
+
1322
+ prompt = _build_auto_prompt("judge", task, root)
1323
+
1324
+ prompt += _build_structured_diff_section(diff)
1325
+
1326
+ prompt += f"\n\n<diff>\n{diff}\n</diff>\n"
1327
+ if session.train_feedback:
1328
+ prompt += f"\n\n<test_feedback>\n{session.train_feedback}\n</test_feedback>\n"
1329
+
1330
+ agent_output_callback = _make_agent_output_callback(monitor, tid, "JUDGE")
1331
+ judge_model = resolve_model_for_phase("JUDGE", root)
1332
+ manifest, _ = _invoke_agent(
1333
+ prompt,
1334
+ c,
1335
+ backend_name=backend,
1336
+ task_id=tid,
1337
+ phase="JUDGE",
1338
+ output_callback=agent_output_callback,
1339
+ model=judge_model,
1340
+ )
1341
+ if manifest is None:
1342
+ raise PhaseFailedError(
1343
+ f"JUDGE phase agent error for {tid}: agent returned no manifest"
1344
+ )
1345
+ verdict = getattr(manifest, "verdict", "")
1346
+ if verdict.upper() == "COMPLIANCE_VIOLATION":
1347
+ # Resolve feedback BEFORE the user-facing print so the operator sees
1348
+ # the same content GREEN will receive. Source precedence:
1349
+ # train_feedback > violations (built) > rationale > summary > fallback
1350
+ # The "summary" branch bridges the schema gap between the auto prompt
1351
+ # (uses summary:) and the manual skill (uses rationale:). The
1352
+ # "violations" branch extracts actionable text from the structured
1353
+ # list both schemas populate on failure.
1354
+ train_feedback_fb = getattr(manifest, "train_feedback", None) or ""
1355
+ rationale_fb = manifest.rationale or ""
1356
+ summary_fb = (
1357
+ getattr(manifest, "summary", None)
1358
+ or (manifest.model_extra or {}).get("summary", "")
1359
+ or ""
1360
+ )
1361
+ violations_fb = _format_violations_as_feedback(
1362
+ (
1363
+ getattr(manifest, "violations", None)
1364
+ or (manifest.model_extra or {}).get("violations", [])
1365
+ or []
1366
+ )
1367
+ )
1368
+ if train_feedback_fb:
1369
+ feedback = train_feedback_fb
1370
+ feedback_source = "train_feedback"
1371
+ elif violations_fb:
1372
+ feedback = violations_fb
1373
+ feedback_source = "violations"
1374
+ elif rationale_fb:
1375
+ feedback = rationale_fb
1376
+ feedback_source = "rationale"
1377
+ elif summary_fb:
1378
+ feedback = summary_fb
1379
+ feedback_source = "summary"
1380
+ else:
1381
+ # No actionable feedback at all — agent failed its contract.
1382
+ # Loud-abort so the operator can intervene instead of looping
1383
+ # GREEN against a generic message until TRAIN_EXHAUSTED.
1384
+ c.print(
1385
+ f" [red]JUDGE_AGENT_NO_FEEDBACK[/] {tid}: judge returned "
1386
+ f"COMPLIANCE_VIOLATION but populated no rationale, "
1387
+ f"train_feedback, summary, or violations"
1388
+ )
1389
+ _log_run(
1390
+ "JUDGE_AGENT_NO_FEEDBACK",
1391
+ task_id=tid,
1392
+ verdict=verdict,
1393
+ manifest=manifest.model_dump_json(),
1394
+ )
1395
+ raise PhaseFailedError(
1396
+ f"JUDGE_AGENT_NO_FEEDBACK for {tid}: judge returned "
1397
+ f"COMPLIANCE_VIOLATION with no actionable feedback"
1398
+ )
1399
+ feedback_preview = feedback.replace("\n", " ")[:200]
1400
+
1401
+ c.print(
1402
+ f" [red]JUDGE_REJECTED[/] {tid} (source={feedback_source}): "
1403
+ f"{feedback_preview}"
1404
+ )
1405
+ _log_run(
1406
+ "JUDGE_REJECTED",
1407
+ task_id=tid,
1408
+ feedback_source=feedback_source,
1409
+ feedback=feedback,
1410
+ )
1411
+
1412
+ session.save(session_path)
1413
+ try:
1414
+ _execute_rollback(root, feedback)
1415
+ except Exception as e:
1416
+ c.print(
1417
+ f" [yellow]ROLLBACK_FAILED[/] {e} \u2014 proceeding with train feedback"
1418
+ )
1419
+
1420
+ tasks_md = _resolve_tasks_md(root, task)
1421
+ if tasks_md is not None:
1422
+ added_lines = _append_judge_feedback(tasks_md, tid, feedback)
1423
+ if added_lines is None:
1424
+ c.print(
1425
+ f" [yellow]TASKS_MD_NO_MATCH[/] {tid}: "
1426
+ f"no task line in {tasks_md} matches this id \u2014 "
1427
+ f"feedback NOT persisted to tasks.md"
1428
+ )
1429
+ _log_run(
1430
+ "TASKS_MD_NO_MATCH",
1431
+ task_id=tid,
1432
+ tasks_md=str(tasks_md),
1433
+ feedback=feedback,
1434
+ )
1435
+ else:
1436
+ plural = "s" if added_lines != 1 else ""
1437
+ c.print(
1438
+ f" [cyan]TASKS_MD_FEEDBACK[/] {tid} \u2192 {tasks_md}: "
1439
+ f"{added_lines} feedback line{plural} appended"
1440
+ )
1441
+ c.print(f" [dim]line: - **Judge Feedback**: {feedback_preview}[/]")
1442
+ _log_run(
1443
+ "TASKS_MD_FEEDBACK",
1444
+ task_id=tid,
1445
+ tasks_md=str(tasks_md),
1446
+ lines_added=added_lines,
1447
+ feedback=feedback,
1448
+ )
1449
+ subprocess.run(
1450
+ ["git", "add", "-A"],
1451
+ cwd=root,
1452
+ capture_output=True,
1453
+ env=_git_env(),
1454
+ )
1455
+ judge_msg = format_commit_message(
1456
+ f"docs({tid}): add judge feedback for GREEN retry", root
1457
+ )
1458
+ subprocess.run(
1459
+ [
1460
+ "git",
1461
+ "commit",
1462
+ "-m",
1463
+ judge_msg,
1464
+ ],
1465
+ cwd=root,
1466
+ capture_output=True,
1467
+ env=_git_env(),
1468
+ )
1469
+ # Advance the red boundary so the next rollback (on a
1470
+ # second judge rejection) preserves this feedback commit
1471
+ # and only kills the subsequent GREEN commit.
1472
+ fb_head = subprocess.run(
1473
+ ["git", "rev-parse", "HEAD"],
1474
+ cwd=root,
1475
+ capture_output=True,
1476
+ text=True,
1477
+ env=_git_env(),
1478
+ ).stdout.strip()
1479
+ if fb_head:
1480
+ session.red_commit_sha = fb_head
1481
+ session.save(session_path)
1482
+ else:
1483
+ c.print(f" [dim]TASKS_MD_SKIP[/] {tid}: no tasks.md resolved for issue")
1484
+ _log_run(
1485
+ "TASKS_MD_SKIP",
1486
+ task_id=tid,
1487
+ reason="no_tasks_md_resolved",
1488
+ )
1489
+
1490
+ _log_run(
1491
+ "PHASE_DECISION",
1492
+ task_id=tid,
1493
+ phase="JUDGE",
1494
+ decision="rejected",
1495
+ reroute="GREEN",
1496
+ )
1497
+ session = session.force_transition_to("GREEN")
1498
+ session.train_feedback = feedback
1499
+ session.judge_rejected = True
1500
+ session.save(session_path)
1501
+ return session
1502
+
1503
+ _log_run("PHASE_DECISION", task_id=tid, phase="JUDGE", decision="passed")
1504
+ refactor_note = (
1505
+ getattr(manifest, "train_feedback", None)
1506
+ or (manifest.model_extra or {}).get("train_feedback", "")
1507
+ or ""
1508
+ )
1509
+ if refactor_note.strip():
1510
+ note_preview = refactor_note.replace("\n", " ")[:200]
1511
+ c.print(f" [cyan]JUDGE_REFACTOR_NOTE[/] {tid}: {note_preview}")
1512
+ _log_run(
1513
+ "JUDGE_REFACTOR_NOTE",
1514
+ task_id=tid,
1515
+ note=refactor_note,
1516
+ )
1517
+ session = session.force_transition_to("JUDGE")
1518
+ session.train_feedback = ""
1519
+ session.judge_rejected = False
1520
+ session.save(session_path)
1521
+ _append_status_transition(task, "JUDGE", ledger_path)
1522
+ return session
1523
+
1524
+
1525
+ def _run_refactor_phase(
1526
+ task: dict,
1527
+ ledger_path: Path,
1528
+ session: SessionState,
1529
+ session_path: Path,
1530
+ c: Console,
1531
+ agent: str | None = None,
1532
+ monitor: OrchestrationMonitor | None = None,
1533
+ ) -> SessionState:
1534
+ tid = task.get("id", "?")
1535
+ if _phase_already_done(ledger_path, task.get("id", ""), "COMPLETED"):
1536
+ c.print(f" [dim]Already completed for {_task_label(task)}, skipping[/]")
1537
+ _log_run(
1538
+ "PHASE_SKIP", task_id=tid, phase="REFACTOR", reason="already_completed"
1539
+ )
1540
+ return session
1541
+ _log_run("PHASE_START", task_id=tid, phase="REFACTOR")
1542
+ c.print(f" [bold green]REFACTOR →[/] {_task_label(task)}")
1543
+
1544
+ backend = agent or "opencode"
1545
+ root = Path.cwd()
1546
+ prompt = _build_auto_prompt("refactor", task, root)
1547
+ agent_output_callback = _make_agent_output_callback(monitor, tid, "REFACTOR")
1548
+ refactor_model = resolve_model_for_phase("REFACTOR", root)
1549
+ manifest, _ = _invoke_agent(
1550
+ prompt,
1551
+ c,
1552
+ backend_name=backend,
1553
+ task_id=tid,
1554
+ phase="REFACTOR",
1555
+ output_callback=agent_output_callback,
1556
+ model=refactor_model,
1557
+ )
1558
+ if manifest is None:
1559
+ raise PhaseFailedError(
1560
+ f"REFACTOR phase agent error for {tid}: agent returned no manifest"
1561
+ )
1562
+ if manifest.status.upper() in ("FAILURE", "ERROR", "FAIL"):
1563
+ raise PhaseFailedError(
1564
+ f"REFACTOR phase failed for {tid}: {manifest.rationale or 'unknown'}"
1565
+ )
1566
+
1567
+ issue_id = task.get("issue_id", "")
1568
+ scope = _build_scope(issue_id, tid)
1569
+
1570
+ _run_test_cmd(root)
1571
+ _run_format_cmd(root)
1572
+
1573
+ try:
1574
+ record = TaskRecord.model_validate(task)
1575
+ record.status = "COMPLETED"
1576
+ append_task_transition(record, ledger_path)
1577
+ except Exception as e:
1578
+ raise PhaseFailedError(f"REFACTOR phase ledger update failed for {tid}: {e}")
1579
+
1580
+ _commit_phase(
1581
+ f"refactor({scope}): REFACTOR phase - cleanup",
1582
+ root,
1583
+ no_verify=True,
1584
+ phase="refactor",
1585
+ )
1586
+
1587
+ session = session.force_transition_to("IDLE")
1588
+ session.save(session_path)
1589
+ _verify_clean_worktree(root, "REFACTOR", tid)
1590
+ c.print(f" [bold green]COMPLETED[/] {_task_label(task)}")
1591
+ return session
1592
+
1593
+
1594
+ _PHASE_MAP: dict[str, Callable] = {
1595
+ "RED": _run_red_phase,
1596
+ "GREEN": _run_green_phase,
1597
+ "JUDGE": _run_judge_phase,
1598
+ "REFACTOR": _run_refactor_phase,
1599
+ }
1600
+
1601
+
1602
+ def _finish_tdd_cycle(
1603
+ task: dict,
1604
+ ledger_path: Path,
1605
+ session: SessionState,
1606
+ session_path: Path,
1607
+ c: Console,
1608
+ no_refactor: bool,
1609
+ monitor: OrchestrationMonitor | None = None,
1610
+ agent: str | None = None,
1611
+ ) -> SessionState:
1612
+ tid = task.get("id", "?")
1613
+ if not no_refactor:
1614
+ _log_run(
1615
+ "PHASE_DECISION", task_id=tid, phase="CYCLE", decision="proceed_to_refactor"
1616
+ )
1617
+ _maybe_push_event(
1618
+ monitor,
1619
+ "phase_change",
1620
+ task_id=tid,
1621
+ phase="REFACTOR",
1622
+ description=task.get("description", ""),
1623
+ )
1624
+ session = _run_refactor_phase(
1625
+ task, ledger_path, session, session_path, c, agent=agent, monitor=monitor
1626
+ )
1627
+ else:
1628
+ _append_status_transition(task, "COMPLETED", ledger_path)
1629
+ c.print(f" [bold green]COMPLETED[/] {_task_label(task)}")
1630
+ session = session.force_transition_to("IDLE")
1631
+ session.train_feedback = ""
1632
+ session.judge_rejected = False
1633
+ session.save(session_path)
1634
+ return session
1635
+
1636
+
1637
+ def _run_tdd_cycle(
1638
+ task: dict,
1639
+ ledger_path: Path,
1640
+ c: Console,
1641
+ no_judge: bool = False,
1642
+ no_refactor: bool = False,
1643
+ agent: str | None = None,
1644
+ monitor: OrchestrationMonitor | None = None,
1645
+ start_phase: str | None = None,
1646
+ ) -> None:
1647
+ root = Path.cwd()
1648
+ tid = task.get("id", "?")
1649
+ if _phase_already_done(ledger_path, tid, "COMPLETED"):
1650
+ c.print(f" [dim]Already completed for {_task_label(task)}, skipping[/]")
1651
+ return
1652
+ _verify_worktree_branch(root)
1653
+ dot_dir = root / ".deviate"
1654
+ session_path = dot_dir / "session.json"
1655
+ session = SessionState.load(session_path)
1656
+
1657
+ task_desc = task.get("description", "")
1658
+
1659
+ if start_phase == "JUDGE":
1660
+ _maybe_push_event(
1661
+ monitor,
1662
+ "phase_change",
1663
+ task_id=tid,
1664
+ phase="JUDGE",
1665
+ description=task_desc,
1666
+ )
1667
+ session = _run_judge_phase(
1668
+ task, ledger_path, session, session_path, c, agent=agent, monitor=monitor
1669
+ )
1670
+
1671
+ session = _finish_tdd_cycle(
1672
+ task, ledger_path, session, session_path, c, no_refactor, agent=agent
1673
+ )
1674
+ return
1675
+
1676
+ _maybe_push_event(
1677
+ monitor, "phase_change", task_id=tid, phase="RED", description=task_desc
1678
+ )
1679
+ session = _run_red_phase(
1680
+ task, ledger_path, session, session_path, c, agent=agent, monitor=monitor
1681
+ )
1682
+ train_attempts = 0
1683
+ max_train_attempts = 3
1684
+ judge_passed = no_judge
1685
+
1686
+ while not judge_passed:
1687
+ _maybe_push_event(
1688
+ monitor, "phase_change", task_id=tid, phase="GREEN", description=task_desc
1689
+ )
1690
+ session = _run_green_phase(
1691
+ task, ledger_path, session, session_path, c, agent=agent, monitor=monitor
1692
+ )
1693
+
1694
+ green_tests_failed = bool(
1695
+ session.train_feedback and session.current_phase == "GREEN"
1696
+ )
1697
+
1698
+ if session.train_feedback:
1699
+ if session.current_phase == "RED":
1700
+ train_attempts += 1
1701
+ if train_attempts >= max_train_attempts:
1702
+ c.print(
1703
+ f" [red]TRAIN_EXHAUSTED[/] {task.get('id', '?')} "
1704
+ f"after {max_train_attempts} attempts"
1705
+ )
1706
+ raise PhaseFailedError(
1707
+ f"GREEN phase post-cleanup failed for {task.get('id', '?')} "
1708
+ f"after {max_train_attempts} train attempts"
1709
+ )
1710
+ c.print(
1711
+ f" [yellow]TRAIN ({train_attempts}/{max_train_attempts})"
1712
+ f" \u2014 GREEN phase post-cleanup failed, retrying with feedback[/]"
1713
+ )
1714
+ _log_run(
1715
+ "PHASE_DECISION",
1716
+ task_id=tid,
1717
+ phase="GREEN",
1718
+ decision="reroute_to_green",
1719
+ reason="post_cleanup_failed",
1720
+ attempt=train_attempts,
1721
+ )
1722
+ continue
1723
+ _log_run(
1724
+ "PHASE_DECISION",
1725
+ task_id=tid,
1726
+ phase="GREEN",
1727
+ decision="tests_failed",
1728
+ reroute="JUDGE",
1729
+ )
1730
+
1731
+ if no_judge:
1732
+ judge_passed = True
1733
+ break
1734
+
1735
+ _maybe_push_event(
1736
+ monitor, "phase_change", task_id=tid, phase="JUDGE", description=task_desc
1737
+ )
1738
+ session = _run_judge_phase(
1739
+ task, ledger_path, session, session_path, c, agent=agent, monitor=monitor
1740
+ )
1741
+
1742
+ if session.judge_rejected or session.train_feedback or green_tests_failed:
1743
+ train_attempts += 1
1744
+ if train_attempts >= max_train_attempts:
1745
+ c.print(
1746
+ f" [red]TRAIN_EXHAUSTED[/] {task.get('id', '?')} "
1747
+ f"after {max_train_attempts} attempts"
1748
+ )
1749
+ raise PhaseFailedError(
1750
+ f"JUDGE phase rejected {task.get('id', '?')} "
1751
+ f"after {max_train_attempts} train attempts"
1752
+ )
1753
+ if session.train_feedback:
1754
+ c.print(
1755
+ f" [yellow]TRAIN ({train_attempts}/{max_train_attempts})"
1756
+ f" \u2014 re-running GREEN with judge feedback[/]"
1757
+ )
1758
+ else:
1759
+ session.train_feedback = (
1760
+ "GREEN implementation tests failed. "
1761
+ "The implementation must be corrected to pass the test suite."
1762
+ )
1763
+ session = session.force_transition_to("GREEN")
1764
+ session.save(session_path)
1765
+ c.print(
1766
+ f" [yellow]TRAIN ({train_attempts}/{max_train_attempts})"
1767
+ f" \u2014 tests still failing, re-running GREEN with test feedback[/]"
1768
+ )
1769
+ session.judge_rejected = False
1770
+ session.save(session_path)
1771
+ _log_run(
1772
+ "PHASE_DECISION",
1773
+ task_id=tid,
1774
+ phase="JUDGE",
1775
+ decision="reroute_to_green",
1776
+ attempt=train_attempts,
1777
+ )
1778
+ continue
1779
+ else:
1780
+ judge_passed = True
1781
+
1782
+ session = _finish_tdd_cycle(
1783
+ task,
1784
+ ledger_path,
1785
+ session,
1786
+ session_path,
1787
+ c,
1788
+ no_refactor,
1789
+ monitor=monitor,
1790
+ agent=agent,
1791
+ )
1792
+
1793
+
1794
+ def _run_execute_phase(
1795
+ task: dict,
1796
+ ledger_path: Path,
1797
+ c: Console,
1798
+ agent: str | None = None,
1799
+ monitor: OrchestrationMonitor | None = None,
1800
+ ) -> None:
1801
+ tid = task.get("id", "?")
1802
+ _log_run("PHASE_START", task_id=tid, phase="EXECUTE")
1803
+ c.print(f" [bold green]EXECUTE →[/] {_task_label(task)}")
1804
+
1805
+ backend = agent or "opencode"
1806
+ root = Path.cwd()
1807
+
1808
+ spec_content = _resolve_spec_md(root, task)
1809
+ has_spec = bool(spec_content)
1810
+ train_feedback = ""
1811
+ max_judge_attempts = 3
1812
+ execute_model = resolve_model_for_phase("EXECUTE", root)
1813
+
1814
+ session_path = root / ".deviate" / "session.json"
1815
+ session = (
1816
+ SessionState.load(session_path) if session_path.exists() else SessionState()
1817
+ )
1818
+ pre_execute_sha = subprocess.run(
1819
+ ["git", "rev-parse", "HEAD"],
1820
+ cwd=root,
1821
+ capture_output=True,
1822
+ text=True,
1823
+ env=_git_env(),
1824
+ ).stdout.strip()
1825
+ session.red_commit_sha = pre_execute_sha
1826
+ session.save(session_path)
1827
+
1828
+ for attempt in range(max_judge_attempts):
1829
+ prompt = _build_auto_prompt("execute", task, root)
1830
+ if train_feedback:
1831
+ prompt += f"\n\n<train_feedback>\n{train_feedback}\n</train_feedback>\n"
1832
+
1833
+ agent_output_callback = _make_agent_output_callback(monitor, tid, "EXECUTE")
1834
+ manifest, _ = _invoke_agent(
1835
+ prompt,
1836
+ c,
1837
+ backend_name=backend,
1838
+ task_id=tid,
1839
+ phase="EXECUTE",
1840
+ output_callback=agent_output_callback,
1841
+ model=execute_model,
1842
+ )
1843
+ if manifest is None:
1844
+ raise PhaseFailedError(
1845
+ f"EXECUTE phase agent error for {tid}: agent returned no manifest"
1846
+ )
1847
+ if manifest.status.upper() in ("FAILURE", "ERROR", "FAIL"):
1848
+ raise PhaseFailedError(
1849
+ f"EXECUTE phase failed for {tid}: {manifest.rationale or 'unknown'}"
1850
+ )
1851
+
1852
+ issue_id = task.get("issue_id", "")
1853
+ scope = _build_scope(issue_id, tid)
1854
+
1855
+ _commit_phase(f"feat({scope}): EXECUTE phase - {tid}", root)
1856
+
1857
+ _verify_clean_worktree(root, "EXECUTE", tid)
1858
+
1859
+ if not has_spec:
1860
+ break
1861
+
1862
+ diff = subprocess.run(
1863
+ ["git", "diff", f"{pre_execute_sha}..HEAD"],
1864
+ cwd=root,
1865
+ capture_output=True,
1866
+ text=True,
1867
+ env=_git_env(),
1868
+ ).stdout
1869
+ if not diff.strip():
1870
+ c.print(f" [dim]JUDGE_SKIP \u2014 no diff in commit for {tid}[/]")
1871
+ break
1872
+
1873
+ c.print(f" [bold magenta]JUDGE →[/] {_task_label(task)} (spec compliance)")
1874
+ judge_prompt = _build_auto_prompt("judge", task, root)
1875
+ judge_prompt += f"\n\n<diff>\n{diff}\n</diff>\n"
1876
+
1877
+ judge_model = resolve_model_for_phase("JUDGE", root)
1878
+ judge_manifest, _ = _invoke_agent(
1879
+ judge_prompt,
1880
+ c,
1881
+ backend_name=backend,
1882
+ task_id=tid,
1883
+ phase="JUDGE",
1884
+ model=judge_model,
1885
+ )
1886
+
1887
+ if judge_manifest is None:
1888
+ raise PhaseFailedError(
1889
+ f"JUDGE phase agent error for {tid}: agent returned no manifest"
1890
+ )
1891
+
1892
+ verdict = getattr(judge_manifest, "verdict", "")
1893
+ if verdict.upper() == "COMPLIANCE_VIOLATION":
1894
+ tf = getattr(judge_manifest, "train_feedback", None) or ""
1895
+ rationale_fb = judge_manifest.rationale or ""
1896
+ summary_fb = (
1897
+ getattr(judge_manifest, "summary", None)
1898
+ or (judge_manifest.model_extra or {}).get("summary", "")
1899
+ or ""
1900
+ )
1901
+ violations_fb = _format_violations_as_feedback(
1902
+ (
1903
+ getattr(judge_manifest, "violations", None)
1904
+ or (judge_manifest.model_extra or {}).get("violations", [])
1905
+ or []
1906
+ )
1907
+ )
1908
+ if tf:
1909
+ feedback = tf
1910
+ feedback_source = "train_feedback"
1911
+ elif violations_fb:
1912
+ feedback = violations_fb
1913
+ feedback_source = "violations"
1914
+ elif rationale_fb:
1915
+ feedback = rationale_fb
1916
+ feedback_source = "rationale"
1917
+ elif summary_fb:
1918
+ feedback = summary_fb
1919
+ feedback_source = "summary"
1920
+ else:
1921
+ c.print(
1922
+ f" [red]JUDGE_AGENT_NO_FEEDBACK[/] {tid}: judge returned "
1923
+ f"COMPLIANCE_VIOLATION but populated no rationale, "
1924
+ f"train_feedback, summary, or violations"
1925
+ )
1926
+ _log_run(
1927
+ "JUDGE_AGENT_NO_FEEDBACK",
1928
+ task_id=tid,
1929
+ verdict=verdict,
1930
+ manifest=judge_manifest.model_dump_json(),
1931
+ )
1932
+ raise PhaseFailedError(
1933
+ f"JUDGE_AGENT_NO_FEEDBACK for {tid}: judge returned "
1934
+ f"COMPLIANCE_VIOLATION with no actionable feedback"
1935
+ )
1936
+ feedback_preview = feedback.replace("\n", " ")[:200]
1937
+
1938
+ c.print(
1939
+ f" [red]JUDGE_REJECTED[/] {tid} (source={feedback_source}): "
1940
+ f"{feedback_preview}"
1941
+ )
1942
+ _log_run(
1943
+ "JUDGE_REJECTED",
1944
+ task_id=tid,
1945
+ feedback_source=feedback_source,
1946
+ feedback=feedback,
1947
+ )
1948
+
1949
+ try:
1950
+ _execute_rollback(root, feedback)
1951
+ except Exception as e:
1952
+ c.print(f" [yellow]ROLLBACK_FAILED[/] {e} — proceeding with retry")
1953
+
1954
+ if attempt < max_judge_attempts - 1:
1955
+ train_feedback = feedback
1956
+ c.print(
1957
+ f" [yellow]RETRY EXECUTE ({attempt + 2}/{max_judge_attempts})[/]"
1958
+ )
1959
+ continue
1960
+ raise PhaseFailedError(
1961
+ f"EXECUTE phase failed for {tid} "
1962
+ f"after {max_judge_attempts} JUDGE attempts: {feedback}"
1963
+ )
1964
+
1965
+ break
1966
+
1967
+ c.print(f" [bold green]COMPLETED[/] {_task_label(task)}")
1968
+ try:
1969
+ record = TaskRecord.model_validate(task)
1970
+ record.status = "COMPLETED"
1971
+ append_task_transition(record, ledger_path)
1972
+ except Exception as e:
1973
+ c.print(f" [yellow]LEDGER_UPDATE_FAILED[/] {e}")
1974
+
1975
+
1976
+ class PhaseFailedError(Exception):
1977
+ pass
1978
+
1979
+
1980
+ class RedPhaseError(Exception):
1981
+ pass
1982
+
1983
+
1984
+ def _dispatch_task(
1985
+ task: dict,
1986
+ ledger_path: Path,
1987
+ c: Console,
1988
+ no_judge: bool = False,
1989
+ no_refactor: bool = False,
1990
+ agent: str | None = None,
1991
+ batch_mode: bool = False,
1992
+ monitor: OrchestrationMonitor | None = None,
1993
+ start_phase: str | None = None,
1994
+ ) -> None:
1995
+ mode = task.get("execution_mode", "TDD")
1996
+ c.print(f"[cyan]Processing {_task_label(task)} ({mode})[/]")
1997
+
1998
+ if mode == "TDD" and batch_mode:
1999
+ description = task.get("description", "")
2000
+ if "Failing task" in description:
2001
+ raise RedPhaseError(
2002
+ f"Task {task.get('id', '?')} failed on RED phase: {description}"
2003
+ )
2004
+
2005
+ if mode == "TDD":
2006
+ _run_tdd_cycle(
2007
+ task,
2008
+ ledger_path,
2009
+ c,
2010
+ no_judge=no_judge,
2011
+ no_refactor=no_refactor,
2012
+ agent=agent,
2013
+ monitor=monitor,
2014
+ start_phase=start_phase,
2015
+ )
2016
+ else:
2017
+ _run_execute_phase(task, ledger_path, c, agent=agent, monitor=monitor)
2018
+
2019
+
2020
+ def _run_single(
2021
+ task_id: str,
2022
+ root: Path,
2023
+ c: Console,
2024
+ no_judge: bool = False,
2025
+ no_refactor: bool = False,
2026
+ agent: str | None = None,
2027
+ ) -> None:
2028
+ result = _resolve_task_context(task_id, root)
2029
+ task, ledger_file = result
2030
+ status = task.get("status", "PENDING")
2031
+
2032
+ dot_dir = root / ".deviate"
2033
+ session_path = dot_dir / "session.json"
2034
+ session = (
2035
+ SessionState.load(session_path) if session_path.exists() else SessionState()
2036
+ )
2037
+
2038
+ if session.current_phase == "IDLE" and status in (
2039
+ "COMPLETED",
2040
+ "REFACTOR",
2041
+ "JUDGE",
2042
+ "YELLOW",
2043
+ ):
2044
+ c.print(f"[yellow]TASK_ALREADY_DONE[/] {task_id} is already completed")
2045
+ raise typer.Exit(code=0)
2046
+
2047
+ start_phase = (
2048
+ session.current_phase if session.current_phase not in ("IDLE", "RED") else None
2049
+ )
2050
+
2051
+ _dispatch_task(
2052
+ task,
2053
+ ledger_file,
2054
+ c,
2055
+ no_judge=no_judge,
2056
+ no_refactor=no_refactor,
2057
+ agent=agent,
2058
+ batch_mode=False,
2059
+ start_phase=start_phase,
2060
+ )
2061
+
2062
+
2063
+ def _execute_task_with_retry(
2064
+ task: dict,
2065
+ ledger_file: Path,
2066
+ c: Console,
2067
+ monitor: OrchestrationMonitor,
2068
+ no_judge: bool = False,
2069
+ no_refactor: bool = False,
2070
+ agent: str | None = None,
2071
+ ) -> bool:
2072
+ tid = task.get("id", "?")
2073
+ mode = task.get("execution_mode", "TDD")
2074
+ _log_run(
2075
+ "TASK_DISPATCH", task_id=tid, mode=mode, description=task.get("description", "")
2076
+ )
2077
+ monitor.push_event(
2078
+ "task_started", task_id=tid, description=task.get("description", "")
2079
+ )
2080
+ for attempt in range(2):
2081
+ try:
2082
+ _dispatch_task(
2083
+ task,
2084
+ ledger_file,
2085
+ c,
2086
+ no_judge=no_judge,
2087
+ no_refactor=no_refactor,
2088
+ agent=agent,
2089
+ batch_mode=True,
2090
+ monitor=monitor,
2091
+ )
2092
+ _log_run("TASK_COMPLETE", task_id=tid, attempt=attempt + 1)
2093
+ monitor.push_event(
2094
+ "task_completed",
2095
+ task_id=tid,
2096
+ phase=monitor.get_task_phase(tid),
2097
+ status="completed",
2098
+ )
2099
+ return True
2100
+ except Exception as exc:
2101
+ if attempt == 1:
2102
+ c.print(f" [red]FAILED[/] {tid} after 2 attempts: {exc}")
2103
+ _log_run("TASK_FAILED", task_id=tid, error=str(exc))
2104
+ monitor.push_event("task_failed", task_id=tid, error_reason=str(exc))
2105
+ _append_status_transition(task, "FAILED", ledger_file)
2106
+ return False
2107
+ c.print(f" [yellow]RETRY[/] {tid} (attempt {attempt + 2})")
2108
+ _log_run("TASK_RETRY", task_id=tid, attempt=attempt + 2)
2109
+
2110
+
2111
+ def _run_all(
2112
+ root: Path,
2113
+ c: Console,
2114
+ no_judge: bool = False,
2115
+ no_refactor: bool = False,
2116
+ agent: str | None = None,
2117
+ json_mode: bool = False,
2118
+ ) -> None:
2119
+ dot_dir = root / ".deviate"
2120
+ session_path = dot_dir / "session.json"
2121
+ session = (
2122
+ SessionState.load(session_path) if session_path.exists() else SessionState()
2123
+ )
2124
+ issue_id = session.active_issue_id
2125
+ if not issue_id:
2126
+ issue_id = _resolve_issue_id_from_branch(root) or issue_id
2127
+
2128
+ pending = _find_all_pending_tasks(root, issue_id=issue_id)
2129
+ if not pending:
2130
+ msg = "No PENDING tasks found"
2131
+ if issue_id:
2132
+ msg += f" for issue {issue_id}"
2133
+ c.print(f"[yellow]{msg}[/]")
2134
+ raise typer.Exit(code=0)
2135
+
2136
+ _log_run(
2137
+ "RUN_ALL_START",
2138
+ issue_id=issue_id or "(none)",
2139
+ pending_count=len(pending),
2140
+ skip_judge=no_judge,
2141
+ skip_refactor=no_refactor,
2142
+ )
2143
+
2144
+ monitor = OrchestrationMonitor(
2145
+ c, json_mode=json_mode, total_tasks=len(pending), verbose=_verbose
2146
+ )
2147
+
2148
+ graphite = resolve_graphite_config(root)
2149
+
2150
+ any_failed = False
2151
+ try:
2152
+ with monitor:
2153
+ for idx, (task, ledger_file) in enumerate(pending):
2154
+ if not _execute_task_with_retry(
2155
+ task,
2156
+ ledger_file,
2157
+ c,
2158
+ monitor,
2159
+ no_judge=no_judge,
2160
+ no_refactor=no_refactor,
2161
+ agent=agent,
2162
+ ):
2163
+ any_failed = True
2164
+ c.print(
2165
+ "[red]Pipeline halted: task failure breaks dependency chain[/]"
2166
+ )
2167
+ monitor.push_event(
2168
+ "pipeline_halted",
2169
+ task_id=task.get("id", "?"),
2170
+ )
2171
+ break
2172
+
2173
+ if graphite and idx < len(pending) - 1:
2174
+ next_task = pending[idx + 1][0]
2175
+ next_id = next_task.get("id", "?")
2176
+ next_desc = next_task.get("description", "")
2177
+ msg = f"feat({next_id}): {next_desc}"
2178
+ try:
2179
+ subprocess.run(
2180
+ ["gt", "create", "-m", msg],
2181
+ capture_output=True,
2182
+ text=True,
2183
+ cwd=root,
2184
+ env=_git_env(),
2185
+ check=True,
2186
+ )
2187
+ c.print(f" [dim]gt create → stacked branch for {next_id}[/]")
2188
+ except subprocess.CalledProcessError as e:
2189
+ c.print(f" [yellow]GT_CREATE_WARN[/] {e.stderr.strip()}")
2190
+ except FileNotFoundError:
2191
+ c.print(" [yellow]GT_CREATE_WARN[/] gt not found on PATH")
2192
+ except KeyboardInterrupt:
2193
+ monitor.signal_keyboard_interrupt()
2194
+ raise typer.Exit(code=130)
2195
+
2196
+ total = len(pending)
2197
+ pipeline_status = (
2198
+ "interrupted"
2199
+ if monitor.interrupted
2200
+ else ("halted" if any_failed else "completed")
2201
+ )
2202
+ _log_run(
2203
+ "RUN_ALL_END",
2204
+ total=total,
2205
+ failed=monitor.failed_count,
2206
+ status=pipeline_status,
2207
+ )
2208
+ monitor.push_event(
2209
+ "pipeline_complete",
2210
+ total=total,
2211
+ failed=monitor.failed_count,
2212
+ status=pipeline_status,
2213
+ )
2214
+
2215
+ if any_failed:
2216
+ raise typer.Exit(code=1)
2217
+
2218
+
2219
+ def _find_test_files(root: Path) -> list[Path]:
2220
+ return sorted(root.glob("tests/**/test_*.py"))
2221
+
2222
+
2223
+ def _find_source_files(root: Path) -> list[Path]:
2224
+ return sorted(root.glob("src/**/*.py"))
2225
+
2226
+
2227
+ def _is_pytest_json_report_available() -> bool:
2228
+ try:
2229
+ import pytest_json_report # noqa: F401
2230
+
2231
+ return True
2232
+ except ImportError:
2233
+ warnings.warn(
2234
+ "pytest-json-report plugin not installed; falling back to string parsing",
2235
+ stacklevel=2,
2236
+ )
2237
+ return False
2238
+
2239
+
2240
+ def _run_pytest(
2241
+ root: Path,
2242
+ report_config: PytestReportConfig | None = None,
2243
+ ) -> subprocess.CompletedProcess:
2244
+ """Invoke pytest as a subprocess against the project test files.
2245
+
2246
+ Tests that exercise CLI commands which internally call this function
2247
+ (e.g. red/green/refactor `_post` commands) MUST mock
2248
+ `deviate.cli.micro._run_pytest` with a `subprocess.CompletedProcess`
2249
+ fixture. Otherwise each test invocation triggers the entire pytest
2250
+ suite (~5s), blowing the <18s full-suite performance target.
2251
+ """
2252
+ test_files = _find_test_files(root)
2253
+ test_file_list = [str(f) for f in test_files]
2254
+ cmd = [sys.executable, "-m", "pytest", *test_file_list, "-v"]
2255
+
2256
+ if report_config is not None and report_config.json_report:
2257
+ if _is_pytest_json_report_available():
2258
+ cmd.append("--json-report")
2259
+
2260
+ return subprocess.run(
2261
+ cmd,
2262
+ cwd=root,
2263
+ capture_output=True,
2264
+ text=True,
2265
+ )
2266
+
2267
+
2268
+ def _commit_phase(
2269
+ message: str,
2270
+ root: Path,
2271
+ no_verify: bool = False,
2272
+ phase: str | None = None,
2273
+ ) -> bool:
2274
+ staged = subprocess.run(
2275
+ ["git", "diff", "--cached", "--quiet"], cwd=root, env=_git_env()
2276
+ )
2277
+ unstaged = subprocess.run(["git", "diff", "--quiet"], cwd=root, env=_git_env())
2278
+ untracked = subprocess.run(
2279
+ ["git", "status", "--porcelain"],
2280
+ cwd=root,
2281
+ capture_output=True,
2282
+ text=True,
2283
+ env=_git_env(),
2284
+ )
2285
+ has_untracked = bool(untracked.stdout.strip())
2286
+ if staged.returncode != 0 or unstaged.returncode != 0 or has_untracked:
2287
+ subprocess.run(["git", "add", "-A"], cwd=root, env=_git_env(), check=False)
2288
+ message = format_commit_message(message, root, phase=phase)
2289
+ cmd = ["git", "commit", "-m", message]
2290
+ if no_verify:
2291
+ cmd.append("--no-verify")
2292
+ result = subprocess.run(
2293
+ cmd,
2294
+ cwd=root,
2295
+ env=_git_env(),
2296
+ capture_output=True,
2297
+ text=True,
2298
+ )
2299
+ if result.returncode != 0:
2300
+ console.print("[red]COMMIT_FAILED[/]")
2301
+ if result.stderr.strip():
2302
+ console.print(result.stderr.strip(), style="red")
2303
+ return False
2304
+ console.print(f" [green]Committed[/] [dim]{message}[/]")
2305
+ return True
2306
+ return False
2307
+
2308
+
2309
+ def _verify_clean_worktree(root: Path, phase: str, tid: str) -> None:
2310
+ status = subprocess.run(
2311
+ ["git", "status", "--porcelain"],
2312
+ cwd=root,
2313
+ capture_output=True,
2314
+ text=True,
2315
+ env=_git_env(),
2316
+ )
2317
+ if status.stdout.strip():
2318
+ files = status.stdout.strip().splitlines()
2319
+ _log_run(
2320
+ "POST_CMD_FAILURE",
2321
+ phase=phase,
2322
+ task_id=tid,
2323
+ uncommitted_count=len(files),
2324
+ files="\n".join(files),
2325
+ )
2326
+ raise PhaseFailedError(
2327
+ f"{phase} phase agent for {tid} did not commit all files \u2014 "
2328
+ f"{len(files)} uncommitted file(s) remain after post-command"
2329
+ )
2330
+
2331
+
2332
+ def _verify_worktree_branch(root: Path) -> None:
2333
+ try:
2334
+ idx = root.parts.index(".worktrees")
2335
+ except ValueError:
2336
+ return
2337
+
2338
+ expected = "/".join(root.parts[idx + 1 :])
2339
+ current = subprocess.run(
2340
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
2341
+ cwd=root,
2342
+ capture_output=True,
2343
+ text=True,
2344
+ env=_git_env(),
2345
+ ).stdout.strip()
2346
+
2347
+ if current != expected:
2348
+ console.print(
2349
+ f" [red]BRANCH_MISMATCH[/] worktree expects"
2350
+ f" [bold]{expected}[/]"
2351
+ f" but HEAD is on [bold]{current}[/]"
2352
+ )
2353
+ console.print(f" Run: git checkout {expected}")
2354
+ raise typer.Exit(code=78)
2355
+
2356
+
2357
+ def _all_tasks_complete(root: Path) -> bool:
2358
+ for ledger_file in sorted(root.glob(_LEDGER_GLOB)):
2359
+ for record in _read_ledger_records(ledger_file):
2360
+ if record.get("status") not in ("COMPLETED", "REFACTOR"):
2361
+ return False
2362
+ return True
2363
+
2364
+
2365
+ def _load_governance_context(root: Path) -> str:
2366
+ parts: list[str] = []
2367
+ constitution_path = root / "specs" / "constitution.md"
2368
+ if constitution_path.exists():
2369
+ parts.append(constitution_path.read_text(encoding="utf-8"))
2370
+ claudemd_path = root / "CLAUDE.md"
2371
+ if claudemd_path.exists():
2372
+ parts.append(claudemd_path.read_text(encoding="utf-8"))
2373
+ if not parts:
2374
+ return ""
2375
+ return "\n\n".join(parts)
2376
+
2377
+
2378
+ def _validate_manifest(manifest_path: str | None) -> dict | None:
2379
+ if manifest_path is None:
2380
+ return None
2381
+ path = Path(manifest_path)
2382
+ if not path.exists():
2383
+ console.print(f"[red]MANIFEST_NOT_FOUND[/] {manifest_path}")
2384
+ raise typer.Exit(code=1)
2385
+ try:
2386
+ data = json.loads(path.read_text(encoding="utf-8"))
2387
+ except json.JSONDecodeError:
2388
+ console.print(f"[red]MANIFEST_INVALID_JSON[/] {manifest_path}")
2389
+ raise typer.Exit(code=1)
2390
+ if not isinstance(data, dict):
2391
+ console.print("[red]MANIFEST_NOT_DICT[/] Manifest must be a JSON object")
2392
+ raise typer.Exit(code=1)
2393
+ return data
2394
+
2395
+
2396
+ @red_app.command(name="pre")
2397
+ def red_pre(
2398
+ task: str | None = typer.Option(None, "--task", "-t", help="Task ID"),
2399
+ ) -> None:
2400
+ root = Path.cwd()
2401
+ task_data, ledger_path = _resolve_task_context(task, root)
2402
+
2403
+ spec_dir = str(ledger_path.parent)
2404
+
2405
+ contract = {
2406
+ "task_id": task_data.get("id", ""),
2407
+ "test_command": "mise run test",
2408
+ "lint_command": "mise run lint",
2409
+ "spec_dir": spec_dir,
2410
+ }
2411
+ print(json.dumps(contract, ensure_ascii=False))
2412
+ raise typer.Exit(code=0)
2413
+
2414
+
2415
+ def _run_test_cmd(root: Path) -> subprocess.CompletedProcess:
2416
+ return subprocess.run(
2417
+ ["mise", "run", "test"],
2418
+ cwd=root,
2419
+ capture_output=True,
2420
+ text=True,
2421
+ )
2422
+
2423
+
2424
+ def _run_format_cmd(root: Path) -> subprocess.CompletedProcess:
2425
+ return subprocess.run(
2426
+ ["mise", "run", "format"],
2427
+ cwd=root,
2428
+ capture_output=True,
2429
+ text=True,
2430
+ )
2431
+
2432
+
2433
+ @red_app.command(name="post")
2434
+ def red_post() -> None:
2435
+ root = Path.cwd()
2436
+ test_files = _find_test_files(root)
2437
+
2438
+ if not test_files:
2439
+ console.print("[red]TEST_NOT_FOUND[/]")
2440
+ raise typer.Exit(code=1)
2441
+
2442
+ proc = _run_test_cmd(root)
2443
+
2444
+ if proc.returncode == 0:
2445
+ console.print("[red]RedMustPassError:[/] Test passed, expected a failing test")
2446
+ raise typer.Exit(code=1)
2447
+
2448
+ fmt = _run_format_cmd(root)
2449
+ if fmt.returncode != 0:
2450
+ console.print(f"[yellow]Format stderr:[/] {fmt.stderr.strip()}")
2451
+ if fmt.stdout.strip():
2452
+ console.print(f"[yellow]Format stdout:[/] {fmt.stdout.strip()}")
2453
+
2454
+ dot_dir = root / ".deviate"
2455
+ session_path = dot_dir / "session.json"
2456
+ session = (
2457
+ SessionState.load(session_path) if session_path.exists() else SessionState()
2458
+ )
2459
+
2460
+ issue_id = session.active_issue_id or ""
2461
+ pending = _resolve_first_pending(root, issue_id)
2462
+ if pending is None:
2463
+ console.print("[red]NO_PENDING_TASKS[/] No PENDING task found for active issue")
2464
+ raise typer.Exit(code=1)
2465
+
2466
+ pending_record, ledger_path = pending
2467
+ task_uuid = pending_record.get("id", "")
2468
+
2469
+ try:
2470
+ record = TaskRecord.model_validate(pending_record)
2471
+ record.status = "RED" # type: ignore[assignment]
2472
+ append_task_transition(record, ledger_path)
2473
+ except Exception as e:
2474
+ console.print(f"[red]LEDGER_UPDATE_FAILED[/] {e}")
2475
+ raise typer.Exit(code=1)
2476
+
2477
+ session = session.force_transition_to("RED")
2478
+ session.save(session_path)
2479
+ scope = _build_scope(issue_id, task_uuid)
2480
+ _commit_phase(
2481
+ f"test({scope}): RED phase - failing test",
2482
+ root,
2483
+ no_verify=True,
2484
+ phase="red",
2485
+ )
2486
+
2487
+ head_sha = subprocess.run(
2488
+ ["git", "rev-parse", "HEAD"],
2489
+ cwd=root,
2490
+ capture_output=True,
2491
+ text=True,
2492
+ env=_git_env(),
2493
+ ).stdout.strip()
2494
+ session.red_commit_sha = head_sha
2495
+ session.save(session_path)
2496
+
2497
+ console.print("[green]RED_POST_OK[/]")
2498
+ raise typer.Exit(code=0)
2499
+
2500
+
2501
+ @green_app.command(name="pre")
2502
+ def green_pre(
2503
+ task: str | None = typer.Option(None, "--task", "-t", help="Task ID"),
2504
+ ) -> None:
2505
+ root = Path.cwd()
2506
+ task_data, ledger_path = _resolve_task_context(task, root)
2507
+
2508
+ test_files = _find_test_files(root)
2509
+ src_files = _find_source_files(root)
2510
+
2511
+ task_id = task_data.get("id", "")
2512
+ task_entry = ""
2513
+ tasks_md = _find_tasks_md_for_issue(root, task_data.get("issue_id", ""))
2514
+ if tasks_md is not None:
2515
+ content = tasks_md.read_text(encoding="utf-8")
2516
+ lines = content.splitlines()
2517
+ capture = False
2518
+ for line in lines:
2519
+ stripped = line.strip()
2520
+ if stripped.startswith("- ") and task_id in stripped:
2521
+ capture = True
2522
+ elif capture and re.match(r"- (?:\[.\]\s+)?TSK-\d{3}-\d{2}:", stripped):
2523
+ break
2524
+ if capture:
2525
+ task_entry += line + "\n"
2526
+
2527
+ contract = {
2528
+ "task_id": task_id,
2529
+ "task_entry": task_entry.strip(),
2530
+ "test_file": str(test_files[0]) if test_files else "",
2531
+ "implementation_targets": [str(f) for f in src_files],
2532
+ }
2533
+ print(json.dumps(contract, ensure_ascii=False))
2534
+ raise typer.Exit(code=0)
2535
+
2536
+
2537
+ @green_app.command(name="post")
2538
+ def green_post() -> None:
2539
+ root = Path.cwd()
2540
+ test_files = _find_test_files(root)
2541
+
2542
+ if not test_files:
2543
+ console.print("[red]TEST_NOT_FOUND[/]")
2544
+ raise typer.Exit(code=1)
2545
+
2546
+ dot_dir = root / ".deviate"
2547
+ session_path = dot_dir / "session.json"
2548
+ session = (
2549
+ SessionState.load(session_path) if session_path.exists() else SessionState()
2550
+ )
2551
+
2552
+ issue_id = session.active_issue_id or ""
2553
+
2554
+ # Verify the specific task has a RED entry (RED phase completed)
2555
+ red_task = _resolve_latest_task(root, issue_id, "RED")
2556
+ if red_task is None:
2557
+ console.print(
2558
+ "[red]MISSING_RED_PHASE[/] No RED transition found — RED phase must complete before GREEN"
2559
+ )
2560
+ raise typer.Exit(code=1)
2561
+
2562
+ task_uuid = red_task[0].get("id", "")
2563
+
2564
+ # Append GREEN transition for this specific task
2565
+ try:
2566
+ record = TaskRecord.model_validate(red_task[0])
2567
+ record.status = "GREEN" # type: ignore[assignment]
2568
+ append_task_transition(record, red_task[1])
2569
+ except Exception as e:
2570
+ console.print(f"[red]LEDGER_UPDATE_FAILED[/] {e}")
2571
+ raise typer.Exit(code=1)
2572
+
2573
+ session = session.force_transition_to("GREEN")
2574
+ session.save(session_path)
2575
+
2576
+ scope = _build_scope(issue_id, task_uuid)
2577
+ status_check = subprocess.run(
2578
+ ["git", "status", "--porcelain"],
2579
+ cwd=root,
2580
+ capture_output=True,
2581
+ text=True,
2582
+ env=_git_env(),
2583
+ )
2584
+ if not status_check.stdout.strip():
2585
+ console.print("[green]GREEN_POST_OK[/]")
2586
+ raise typer.Exit(code=0)
2587
+
2588
+ committed = _commit_phase(
2589
+ f"feat({scope}): GREEN phase - implementation passes tests", root
2590
+ )
2591
+
2592
+ if committed:
2593
+ console.print("[green]GREEN_POST_OK[/]")
2594
+ else:
2595
+ console.print("[red]COMMIT_FAILED[/]")
2596
+
2597
+ raise typer.Exit(code=0 if committed else 1)
2598
+
2599
+
2600
+ # ---------------------------------------------------------------------------
2601
+ # YELLOW commands
2602
+ # ---------------------------------------------------------------------------
2603
+
2604
+
2605
+ def _detect_phase_changes(root: Path) -> list[str]:
2606
+ status = subprocess.run(
2607
+ ["git", "status", "--porcelain"],
2608
+ cwd=root,
2609
+ capture_output=True,
2610
+ text=True,
2611
+ env=_git_env(),
2612
+ )
2613
+ files: list[str] = []
2614
+ for line in status.stdout.splitlines():
2615
+ if not line.strip():
2616
+ continue
2617
+ filename = line[3:]
2618
+ files.append(filename)
2619
+
2620
+ expanded: list[str] = []
2621
+ for f in files:
2622
+ if f.endswith("/"):
2623
+ full_dir = root / f
2624
+ if full_dir.is_dir():
2625
+ for py_file in sorted(full_dir.rglob("*.py")):
2626
+ rel = py_file.relative_to(root)
2627
+ expanded.append(str(rel))
2628
+ else:
2629
+ expanded.append(f)
2630
+ else:
2631
+ expanded.append(f)
2632
+ return expanded
2633
+
2634
+
2635
+ # ---------------------------------------------------------------------------
2636
+ # JUDGE commands
2637
+ # ---------------------------------------------------------------------------
2638
+
2639
+
2640
+ def _find_protected_modules(root: Path) -> list[str]:
2641
+ modules: list[str] = []
2642
+ for spec_file in sorted(root.glob("specs/**/issues/*.md")):
2643
+ content = spec_file.read_text(encoding="utf-8")
2644
+ for line in content.splitlines():
2645
+ stripped = line.strip()
2646
+ if stripped.startswith("Module:"):
2647
+ module_path = stripped[len("Module:") :].strip()
2648
+ modules.append(module_path)
2649
+ return modules
2650
+
2651
+
2652
+ @judge_app.command(name="pre")
2653
+ def judge_pre() -> None:
2654
+ root = Path.cwd()
2655
+
2656
+ if not _load_skill_content("JUDGE"):
2657
+ console.print("[yellow]SKILL_NOT_FOUND[/] deviate-judge")
2658
+
2659
+ changed = _detect_phase_changes(root)
2660
+
2661
+ protected = _find_protected_modules(root)
2662
+ violations: list[dict[str, str]] = []
2663
+ for changed_file in changed:
2664
+ for protected_path in protected:
2665
+ changed_normalized = changed_file.rstrip("/")
2666
+ if changed_normalized == protected_path:
2667
+ violations.append(
2668
+ {
2669
+ "file": changed_file,
2670
+ "protected_module": protected_path,
2671
+ }
2672
+ )
2673
+ elif protected_path.startswith(changed_normalized + "/"):
2674
+ violations.append(
2675
+ {
2676
+ "file": changed_file,
2677
+ "protected_module": protected_path,
2678
+ }
2679
+ )
2680
+
2681
+ verdict = {
2682
+ "verdict": "COMPLIANCE_VIOLATION" if violations else "COMPLIANCE_PASS",
2683
+ "details": violations,
2684
+ }
2685
+ print(json.dumps(verdict, ensure_ascii=False))
2686
+ raise typer.Exit(code=0)
2687
+
2688
+
2689
+ # ---------------------------------------------------------------------------
2690
+ # REFACTOR commands
2691
+ # ---------------------------------------------------------------------------
2692
+
2693
+
2694
+ _NON_DETERMINISTIC = re.compile(
2695
+ r"(0x[0-9a-fA-F]+|id='\d+'|pytest-\d+/|\[?[a-f0-9]{7}\])"
2696
+ )
2697
+
2698
+
2699
+ def _normalize_pytest_output(output: str) -> str:
2700
+ lines: list[str] = []
2701
+ for line in output.splitlines():
2702
+ stripped = line.strip()
2703
+ if stripped.startswith("==="):
2704
+ continue
2705
+ if "collected " in stripped and "item" in stripped:
2706
+ continue
2707
+ if stripped.startswith(".") and stripped.endswith("%]"):
2708
+ continue
2709
+ normalized = _NON_DETERMINISTIC.sub("", stripped)
2710
+ lines.append(normalized)
2711
+ return "\n".join(lines)
2712
+
2713
+
2714
+ @refactor_app.command(name="pre")
2715
+ def refactor_pre(
2716
+ task: str | None = typer.Option(None, "--task", "-t", help="Task ID"),
2717
+ ) -> None:
2718
+ root = Path.cwd()
2719
+ _resolve_task_context(task, root)
2720
+
2721
+ src_files = [str(f) for f in _find_source_files(root)]
2722
+
2723
+ contract = {"files_to_refactor": src_files}
2724
+ print(json.dumps(contract, ensure_ascii=False))
2725
+ raise typer.Exit(code=0)
2726
+
2727
+
2728
+ def _check_python_return_types(filepath: str) -> list[str]:
2729
+ """Check Python return type annotations against literal return values using tree-sitter."""
2730
+ issues: list[str] = []
2731
+
2732
+ tree = incremental_parse(filepath, None)
2733
+ if tree is None:
2734
+ return issues
2735
+
2736
+ scalar_types = {
2737
+ "str": "string",
2738
+ "int": "integer",
2739
+ "float": "float",
2740
+ "bool": "boolean",
2741
+ }
2742
+ collection_types = {
2743
+ "list": "list",
2744
+ "dict": "dictionary",
2745
+ "tuple": "tuple",
2746
+ "set": "set",
2747
+ }
2748
+ all_known = set(scalar_types) | set(collection_types)
2749
+
2750
+ def _get_return_type_name(func_node: object) -> str | None:
2751
+ for child in func_node.children:
2752
+ if child.type == "type":
2753
+ nodes = [child]
2754
+ while nodes:
2755
+ curr = nodes.pop(0)
2756
+ if curr.type == "identifier":
2757
+ return curr.text.decode("utf-8", errors="replace")
2758
+ if curr.type == "string":
2759
+ return curr.text.decode("utf-8", errors="replace").strip("'\"")
2760
+ nodes.extend(curr.children)
2761
+ break
2762
+ return None
2763
+
2764
+ def _check_return_value(return_node: object, expected: str) -> list[str]:
2765
+ result: list[str] = []
2766
+ for rc in return_node.children:
2767
+ if rc.type in ("return", ","):
2768
+ continue
2769
+ if rc.type in scalar_types.values():
2770
+ if expected in scalar_types and rc.type != scalar_types[expected]:
2771
+ result.append(f"expected {expected}, got literal {rc.type}")
2772
+ elif rc.type in collection_types.values():
2773
+ for cname, ctype in collection_types.items():
2774
+ if rc.type == ctype and expected != cname:
2775
+ result.append(f"expected {expected}, got {cname} literal")
2776
+ break
2777
+ elif rc.type in ("true", "false"):
2778
+ if expected != "bool":
2779
+ result.append(f"expected {expected}, got literal bool")
2780
+ break
2781
+ return result
2782
+
2783
+ stack = [tree.root_node]
2784
+ while stack:
2785
+ node = stack.pop()
2786
+ if node.type != "function_definition":
2787
+ for child in node.children:
2788
+ stack.append(child)
2789
+ continue
2790
+
2791
+ ret_type = _get_return_type_name(node)
2792
+ if ret_type is None or ret_type not in all_known:
2793
+ for child in node.children:
2794
+ stack.append(child)
2795
+ continue
2796
+
2797
+ # Walk the function body for return statements
2798
+ body = None
2799
+ for child in node.children:
2800
+ if child.type == "block":
2801
+ body = child
2802
+ break
2803
+ if body is not None:
2804
+ bs = [body]
2805
+ while bs:
2806
+ bn = bs.pop()
2807
+ if bn.type == "return_statement":
2808
+ issues.extend(_check_return_value(bn, ret_type))
2809
+ elif bn.type in ("function_definition", "class_definition"):
2810
+ continue
2811
+ for child in bn.children:
2812
+ bs.append(child)
2813
+
2814
+ for child in node.children:
2815
+ stack.append(child)
2816
+
2817
+ return issues
2818
+
2819
+
2820
+ def _check_return_type_mismatch(filepath: str) -> list[str]:
2821
+ """Check return type mismatches and structural issues using tree-sitter.
2822
+
2823
+ For Python files, checks return type annotations against literal return values.
2824
+ For all supported languages, detects dead code, duplicate blocks, and high cyclomatic complexity.
2825
+ """
2826
+ issues: list[str] = []
2827
+
2828
+ lang_id = get_language_id(filepath)
2829
+ if lang_id is None:
2830
+ return issues
2831
+
2832
+ # Python-specific: return type annotation check
2833
+ if lang_id == "python":
2834
+ issues.extend(_check_python_return_types(filepath))
2835
+
2836
+ dead = extract_dead_code(filepath)
2837
+ dupes = detect_duplicate_blocks(filepath, min_lines=5)
2838
+
2839
+ for block in dupes:
2840
+ locs = ", ".join(block.locations)
2841
+ issues.append(f"Duplicate block ({block.lines} lines) at {locs}")
2842
+
2843
+ tree = incremental_parse(filepath, None)
2844
+ if tree is not None:
2845
+ has_calls = False
2846
+ func_types = {
2847
+ "function_definition",
2848
+ "function_declaration",
2849
+ "function_item",
2850
+ "method_definition",
2851
+ "method_declaration",
2852
+ }
2853
+ fstack = [tree.root_node]
2854
+ while fstack:
2855
+ node = fstack.pop()
2856
+ if not has_calls and node.type in ("call", "call_expression"):
2857
+ has_calls = True
2858
+ if node.type in func_types:
2859
+ name = "unknown"
2860
+ for child in node.children:
2861
+ if child.type in ("identifier", "property_identifier", "name"):
2862
+ name = child.text.decode("utf-8", errors="replace")
2863
+ break
2864
+ complexity = estimate_cyclomatic_complexity(filepath, node)
2865
+ if complexity >= 10:
2866
+ issues.append(
2867
+ f"Complexity warning: '{name}' has cyclomatic complexity {complexity} (threshold: 10)"
2868
+ )
2869
+ for child in node.children:
2870
+ fstack.append(child)
2871
+
2872
+ if has_calls and dead:
2873
+ for name in dead:
2874
+ issues.append(f"Dead code: '{name}' is defined but never used")
2875
+
2876
+ return issues
2877
+
2878
+
2879
+ @refactor_app.command(name="post")
2880
+ def refactor_post() -> None:
2881
+ root = Path.cwd()
2882
+ test_files = _find_test_files(root)
2883
+
2884
+ if not test_files:
2885
+ console.print("[yellow]NO_TESTS_TO_CHECK[/]")
2886
+ raise typer.Exit(code=0)
2887
+
2888
+ dot_dir = root / ".deviate"
2889
+ session_path = dot_dir / "session.json"
2890
+ session = (
2891
+ SessionState.load(session_path) if session_path.exists() else SessionState()
2892
+ )
2893
+
2894
+ issue_id = session.active_issue_id or ""
2895
+
2896
+ # Verify the specific task has a GREEN entry (GREEN phase completed)
2897
+ green_task = _resolve_latest_task(root, issue_id, "GREEN")
2898
+ if green_task is None:
2899
+ console.print(
2900
+ "[red]MISSING_GREEN_PHASE[/] No GREEN transition found — GREEN phase must complete before REFACTOR"
2901
+ )
2902
+ raise typer.Exit(code=1)
2903
+
2904
+ task_uuid = green_task[0].get("id", "")
2905
+
2906
+ try:
2907
+ record = TaskRecord.model_validate(green_task[0])
2908
+ record.status = "COMPLETED" # type: ignore[assignment]
2909
+ append_task_transition(record, green_task[1])
2910
+ except Exception as e:
2911
+ console.print(f"[red]LEDGER_UPDATE_FAILED[/] {e}")
2912
+ raise typer.Exit(code=1)
2913
+
2914
+ session = session.force_transition_to("IDLE")
2915
+ session.save(session_path)
2916
+
2917
+ scope = _build_scope(issue_id, task_uuid)
2918
+
2919
+ proc_before = _run_pytest(root)
2920
+ before_returncode = proc_before.returncode
2921
+ before_output = _normalize_pytest_output(proc_before.stdout)
2922
+
2923
+ changed = _detect_phase_changes(root)
2924
+ for changed_file in changed:
2925
+ full_path = root / changed_file
2926
+ if full_path.suffix == ".py" and full_path.exists():
2927
+ type_issues = _check_return_type_mismatch(str(full_path))
2928
+ if type_issues:
2929
+ subprocess.run(
2930
+ ["git", "restore", "."], cwd=root, env=_git_env(), check=False
2931
+ )
2932
+ console.print(
2933
+ "[red]RefactorRegressionError:[/] " + "; ".join(type_issues)
2934
+ )
2935
+ raise typer.Exit(code=1)
2936
+
2937
+ proc_after = _run_pytest(root)
2938
+ after_returncode = proc_after.returncode
2939
+ after_output = _normalize_pytest_output(proc_after.stdout)
2940
+
2941
+ if after_returncode != before_returncode or after_output != before_output:
2942
+ subprocess.run(["git", "restore", "."], cwd=root, env=_git_env(), check=False)
2943
+ console.print(
2944
+ "[red]RefactorRegressionError:[/] Test regression detected after refactor"
2945
+ )
2946
+ raise typer.Exit(code=1)
2947
+
2948
+ committed = _commit_phase(
2949
+ f"refactor({scope}): REFACTOR phase \u2014 code cleanup", root
2950
+ )
2951
+
2952
+ if committed:
2953
+ console.print("[green]REFACTOR_POST_OK[/]")
2954
+
2955
+ task_record = green_task[0]
2956
+ _append_status_transition(task_record, "COMPLETED", green_task[1])
2957
+ console.print(f" [bold green]COMPLETED[/] {task_uuid}")
2958
+
2959
+ session = session.force_transition_to("IDLE")
2960
+ session.save(session_path)
2961
+ else:
2962
+ console.print("[yellow]NOTHING_CHANGED[/]")
2963
+
2964
+ raise typer.Exit(code=0)
2965
+
2966
+
2967
+ # ---------------------------------------------------------------------------
2968
+ # EXECUTE commands (DIRECT mode — bypasses RED/GREEN/REFACTOR)
2969
+ # ---------------------------------------------------------------------------
2970
+ # RED-phase stubs — minimum structure so CLI commands are routable;
2971
+ # tests fail because the real contract emission, validation, and ledger
2972
+ # updates are not yet implemented (GREEN phase).
2973
+
2974
+
2975
+ @execute_app.command(name="pre")
2976
+ def execute_pre(
2977
+ task: str | None = typer.Option(None, "--task", "-t", help="Task ID"),
2978
+ ) -> None:
2979
+ root = Path.cwd()
2980
+ task_data, _ = _resolve_task_context(task, root)
2981
+
2982
+ dot_dir = root / ".deviate"
2983
+ session_path = dot_dir / "session.json"
2984
+ session = (
2985
+ SessionState.load(session_path) if session_path.exists() else SessionState()
2986
+ )
2987
+ session = session.force_transition_to("EXECUTE")
2988
+ session.active_issue_id = task_data.get("issue_id")
2989
+ session.save(session_path)
2990
+
2991
+ contract = {
2992
+ "task_id": task_data.get("id", ""),
2993
+ "completion_criteria": "Direct execution task \u2014 bypasses RED/GREEN/REFACTOR",
2994
+ }
2995
+ print(json.dumps(contract, ensure_ascii=False))
2996
+ raise typer.Exit(code=0)
2997
+
2998
+
2999
+ @execute_app.command(name="post")
3000
+ def execute_post(
3001
+ task_id: str | None = typer.Argument(
3002
+ None, help="Task ID (auto-discovered from session if empty)"
3003
+ ),
3004
+ subject: str = typer.Argument(
3005
+ "", help="Commit subject (auto-generated from task ID if empty)"
3006
+ ),
3007
+ body: str | None = typer.Argument(None, help="Optional commit body"),
3008
+ ) -> None:
3009
+ root = Path.cwd()
3010
+
3011
+ if task_id:
3012
+ result = _find_task_record(root, task_id)
3013
+ else:
3014
+ result = _resolve_task_context(None, root)
3015
+
3016
+ if result is not None:
3017
+ task_record, ledger_path = result
3018
+ resolved_task_id = task_record.get("id", task_id or "?")
3019
+ _append_status_transition(task_record, "COMPLETED", ledger_path)
3020
+ else:
3021
+ resolved_task_id = task_id or "?"
3022
+
3023
+ if not subject:
3024
+ subject = f"feat({resolved_task_id}): execute result"
3025
+
3026
+ message = subject
3027
+ if body:
3028
+ message += "\n\n" + body
3029
+
3030
+ _commit_phase(message, root)
3031
+ raise typer.Exit(code=0)
3032
+
3033
+
3034
+ # ---------------------------------------------------------------------------
3035
+ # E2E commands (end-to-end verification after all tasks complete)
3036
+ # ---------------------------------------------------------------------------
3037
+
3038
+
3039
+ @e2e_app.command(name="pre")
3040
+ def e2e_pre() -> None:
3041
+ root = Path.cwd()
3042
+
3043
+ if not _all_tasks_complete(root):
3044
+ console.print("[red]INCOMPLETE_TASKS[/] Some tasks not completed")
3045
+ raise typer.Exit(code=1)
3046
+
3047
+ test_paths = [str(p) for p in _find_test_files(root)]
3048
+ contract = {"test_paths": test_paths}
3049
+ print(json.dumps(contract, ensure_ascii=False))
3050
+ raise typer.Exit(code=0)
3051
+
3052
+
3053
+ @e2e_app.command(name="post")
3054
+ def e2e_post(
3055
+ manifest: str | None = typer.Argument(None, help="Path to manifest file"),
3056
+ ) -> None:
3057
+ root = Path.cwd()
3058
+ manifest_data = _validate_manifest(manifest)
3059
+ subject = (
3060
+ manifest_data.get("commit_subject", "feat: E2E phase")
3061
+ if manifest_data
3062
+ else "feat: E2E phase"
3063
+ )
3064
+ _commit_phase(subject, root)
3065
+ raise typer.Exit(code=0)
3066
+
3067
+
3068
+ # ---------------------------------------------------------------------------
3069
+ # HOTFIX commands (bug fixes — bypasses RED phase)
3070
+ # ---------------------------------------------------------------------------
3071
+
3072
+
3073
+ @hotfix_app.command(name="pre")
3074
+ def hotfix_pre(
3075
+ task: str | None = typer.Option(None, "--task", "-t", help="Task ID"),
3076
+ ) -> None:
3077
+ root = Path.cwd()
3078
+ task_data, _ = _resolve_task_context(task, root)
3079
+
3080
+ contract = {
3081
+ "issue_context": task_data.get("description", ""),
3082
+ "bypasses_red": True,
3083
+ "completion_criteria": "Bug fix \u2014 bypasses RED phase",
3084
+ }
3085
+ print(json.dumps(contract, ensure_ascii=False))
3086
+ raise typer.Exit(code=0)
3087
+
3088
+
3089
+ @hotfix_app.command(name="post")
3090
+ def hotfix_post(
3091
+ manifest: str | None = typer.Argument(None, help="Path to manifest file"),
3092
+ ) -> None:
3093
+ root = Path.cwd()
3094
+ manifest_data = _validate_manifest(manifest)
3095
+ subject = (
3096
+ manifest_data.get("commit_subject", "feat: HOTFIX phase")
3097
+ if manifest_data
3098
+ else "feat: HOTFIX phase"
3099
+ )
3100
+ _commit_phase(subject, root)
3101
+ raise typer.Exit(code=0)
3102
+
3103
+
3104
+ def _resolve_agent_config(root: Path, agent: str | None) -> str | None:
3105
+ """Resolve agent backend from CLI arg or config.toml fallback.
3106
+
3107
+ User-facing aliases (``factory`` for the Factory Droid IDE, ``omp``
3108
+ for Oh-My-Pi) are normalised to their canonical backend via
3109
+ :func:`deviate.core.agent.resolve_agent_to_backend` so the returned
3110
+ value is always a valid :class:`~deviate.state.config.AgentConfig`
3111
+ ``backend`` Literal. The ``run`` dispatch layer therefore never sees
3112
+ a raw alias — it only sees canonical backend identifiers.
3113
+ """
3114
+ if agent is not None:
3115
+ return resolve_agent_to_backend(agent)
3116
+ config_path = root / ".deviate" / "config.toml"
3117
+ if not config_path.exists():
3118
+ return None
3119
+ try:
3120
+ import tomllib
3121
+
3122
+ with open(config_path, "rb") as f:
3123
+ data = tomllib.load(f)
3124
+ backend = data.get("agent", {}).get("backend")
3125
+ if not isinstance(backend, str) or not backend:
3126
+ return None
3127
+ return resolve_agent_to_backend(backend)
3128
+ except Exception:
3129
+ return None
3130
+
3131
+
3132
+ def _validate_profile(value: str) -> str:
3133
+ """Typer callback: validate profile via resolve_profile, emit Typer error."""
3134
+ try:
3135
+ resolve_profile(value)
3136
+ except ValueError as e:
3137
+ raise typer.BadParameter(str(e)) from e
3138
+ return value
3139
+
3140
+
3141
+ def run_command(
3142
+ task_id: str | None = typer.Argument(
3143
+ None, help="Task ID (TNNN or TSK-NNN-NN format)"
3144
+ ),
3145
+ all_tasks: bool = typer.Option(False, "--all", help="Run all PENDING tasks"),
3146
+ profile: str = typer.Option(
3147
+ "full",
3148
+ "--profile",
3149
+ callback=_validate_profile,
3150
+ help="Execution profile: full, fast, secure",
3151
+ ),
3152
+ no_judge: bool | None = typer.Option(None, "--no-judge", help="Skip JUDGE phase"),
3153
+ no_refactor: bool | None = typer.Option(
3154
+ None, "--no-refactor", help="Skip REFACTOR phase"
3155
+ ),
3156
+ agent: str | None = typer.Option(None, "--agent", help="Override agent backend"),
3157
+ json_mode: bool = typer.Option(False, "--json", help="Emit JSONL output"),
3158
+ dry_run: bool = typer.Option(
3159
+ False, "--dry-run", help="Print resolved task and exit"
3160
+ ),
3161
+ verbose: bool = typer.Option(False, "--verbose", help="Print debug diagnostics"),
3162
+ ) -> None:
3163
+ """Run dispatcher: route task by execution_mode to TDD cycle or execute phase.
3164
+
3165
+ When called without arguments, picks the next PENDING task for the active issue.
3166
+ """
3167
+ global _verbose
3168
+ _verbose = verbose
3169
+
3170
+ root = _resolve_workspace_root()
3171
+ session_path = root / ".deviate" / "session.json"
3172
+
3173
+ _log(f"workspace root: {root}")
3174
+ _log(f"session path: {session_path}")
3175
+
3176
+ agent = _resolve_agent_config(root, agent)
3177
+
3178
+ if session_path.exists():
3179
+ session = SessionState.load(session_path)
3180
+ _log(f"session: phase={session.current_phase}, issue={session.active_issue_id}")
3181
+ cmd_parts = ["run"]
3182
+ if task_id:
3183
+ cmd_parts.append(task_id)
3184
+ if all_tasks:
3185
+ cmd_parts.append("--all")
3186
+ session = SessionState(
3187
+ current_phase=session.current_phase,
3188
+ active_issue_id=session.active_issue_id,
3189
+ last_command=" ".join(cmd_parts),
3190
+ )
3191
+ session.save(session_path)
3192
+
3193
+ if dry_run:
3194
+ _log("dry-run mode — resolving tasks without execution")
3195
+ if all_tasks:
3196
+ pending = _find_all_pending_tasks(root)
3197
+ if not pending:
3198
+ console.print("[yellow]NO_PENDING_TASKS[/]")
3199
+ for rec, path in pending:
3200
+ console.print(
3201
+ f" {rec.get('id')}: {rec.get('status')} "
3202
+ f"— {rec.get('description', '')[:60]}"
3203
+ )
3204
+ else:
3205
+ try:
3206
+ result = _resolve_task_context(task_id, root)
3207
+ task, path = result
3208
+ console.print(
3209
+ f" {task.get('id')}: {task.get('status')} "
3210
+ f"— {task.get('description', '')[:60]}"
3211
+ )
3212
+ console.print(f" ledger: {path}")
3213
+ except typer.Exit:
3214
+ if _verbose:
3215
+ console.print("[yellow]No task resolved[/]")
3216
+ raise typer.Exit(code=0)
3217
+
3218
+ skip_judge, skip_refactor = resolve_profile(profile, no_judge, no_refactor)
3219
+
3220
+ run_logger = RunLogger(root)
3221
+ _log_run(
3222
+ "RUN_START",
3223
+ command=f"deviate run {task_id or ''} {'--all' if all_tasks else ''}".strip(),
3224
+ )
3225
+ set_run_logger(run_logger)
3226
+
3227
+ try:
3228
+ if all_tasks:
3229
+ _run_all(
3230
+ root,
3231
+ console,
3232
+ no_judge=skip_judge,
3233
+ no_refactor=skip_refactor,
3234
+ agent=agent,
3235
+ json_mode=json_mode,
3236
+ )
3237
+ raise typer.Exit(code=0)
3238
+
3239
+ _run_single(
3240
+ task_id,
3241
+ root,
3242
+ console,
3243
+ no_judge=skip_judge,
3244
+ no_refactor=skip_refactor,
3245
+ agent=agent,
3246
+ )
3247
+ finally:
3248
+ run_logger.close()
3249
+ set_run_logger(None)