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.
- deviatdd-2.5.1.dist-info/METADATA +386 -0
- deviatdd-2.5.1.dist-info/RECORD +124 -0
- deviatdd-2.5.1.dist-info/WHEEL +4 -0
- deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
- deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
- deviate/__init__.py +3 -0
- deviate/cli/__init__.py +824 -0
- deviate/cli/_common.py +155 -0
- deviate/cli/adhoc.py +183 -0
- deviate/cli/constitution.py +113 -0
- deviate/cli/feature.py +94 -0
- deviate/cli/init.py +485 -0
- deviate/cli/inspect.py +208 -0
- deviate/cli/macro.py +937 -0
- deviate/cli/meso.py +1894 -0
- deviate/cli/micro.py +3249 -0
- deviate/cli/review.py +441 -0
- deviate/core/__init__.py +0 -0
- deviate/core/_shared.py +20 -0
- deviate/core/agent.py +505 -0
- deviate/core/cache_discipline.py +65 -0
- deviate/core/commands.py +202 -0
- deviate/core/commit.py +86 -0
- deviate/core/complexity.py +36 -0
- deviate/core/constitution.py +85 -0
- deviate/core/contract.py +17 -0
- deviate/core/convention.py +147 -0
- deviate/core/epic.py +73 -0
- deviate/core/issues.py +46 -0
- deviate/core/prd.py +18 -0
- deviate/core/profile.py +33 -0
- deviate/core/repo.py +47 -0
- deviate/core/run_logger.py +50 -0
- deviate/core/tasks_ledger.py +69 -0
- deviate/core/treesitter/__init__.py +31 -0
- deviate/core/treesitter/analysis.py +457 -0
- deviate/core/treesitter/models.py +35 -0
- deviate/core/treesitter/parser.py +184 -0
- deviate/core/treesitter/queries/bash.scm +7 -0
- deviate/core/treesitter/queries/c_sharp.scm +11 -0
- deviate/core/treesitter/queries/cpp.scm +9 -0
- deviate/core/treesitter/queries/css.scm +4 -0
- deviate/core/treesitter/queries/dockerfile.scm +8 -0
- deviate/core/treesitter/queries/elixir.scm +6 -0
- deviate/core/treesitter/queries/go.scm +9 -0
- deviate/core/treesitter/queries/hcl.scm +3 -0
- deviate/core/treesitter/queries/html.scm +3 -0
- deviate/core/treesitter/queries/javascript.scm +12 -0
- deviate/core/treesitter/queries/json.scm +4 -0
- deviate/core/treesitter/queries/kotlin.scm +8 -0
- deviate/core/treesitter/queries/markdown.scm +4 -0
- deviate/core/treesitter/queries/python.scm +10 -0
- deviate/core/treesitter/queries/rust.scm +12 -0
- deviate/core/treesitter/queries/sql.scm +7 -0
- deviate/core/treesitter/queries/swift.scm +10 -0
- deviate/core/treesitter/queries/toml.scm +3 -0
- deviate/core/treesitter/queries/tsx.scm +14 -0
- deviate/core/treesitter/queries/typescript.scm +14 -0
- deviate/core/treesitter/queries/yaml.scm +4 -0
- deviate/core/validation.py +153 -0
- deviate/core/worktree.py +141 -0
- deviate/main.py +4 -0
- deviate/prompts/__init__.py +0 -0
- deviate/prompts/assembly.py +122 -0
- deviate/prompts/auto/__init__.py +1 -0
- deviate/prompts/auto/execute.md +62 -0
- deviate/prompts/auto/explore.md +103 -0
- deviate/prompts/auto/green.md +137 -0
- deviate/prompts/auto/judge.md +260 -0
- deviate/prompts/auto/plan.md +127 -0
- deviate/prompts/auto/prd.md +108 -0
- deviate/prompts/auto/red.md +156 -0
- deviate/prompts/auto/refactor.md +166 -0
- deviate/prompts/auto/research.md +111 -0
- deviate/prompts/auto/shard.md +90 -0
- deviate/prompts/auto/specify.md +77 -0
- deviate/prompts/auto/tasks.md +191 -0
- deviate/prompts/commands/deviate-adhoc.md +216 -0
- deviate/prompts/commands/deviate-architecture.md +147 -0
- deviate/prompts/commands/deviate-constitution.md +215 -0
- deviate/prompts/commands/deviate-e2e.md +264 -0
- deviate/prompts/commands/deviate-execute.md +223 -0
- deviate/prompts/commands/deviate-explore.md +253 -0
- deviate/prompts/commands/deviate-flows.md +226 -0
- deviate/prompts/commands/deviate-green.md +217 -0
- deviate/prompts/commands/deviate-hotfix.md +188 -0
- deviate/prompts/commands/deviate-init.md +170 -0
- deviate/prompts/commands/deviate-judge.md +193 -0
- deviate/prompts/commands/deviate-merge.md +221 -0
- deviate/prompts/commands/deviate-plan.md +158 -0
- deviate/prompts/commands/deviate-pr.md +144 -0
- deviate/prompts/commands/deviate-prd.md +216 -0
- deviate/prompts/commands/deviate-prune.md +260 -0
- deviate/prompts/commands/deviate-red.md +231 -0
- deviate/prompts/commands/deviate-refactor.md +211 -0
- deviate/prompts/commands/deviate-release.md +123 -0
- deviate/prompts/commands/deviate-research.md +359 -0
- deviate/prompts/commands/deviate-review.md +289 -0
- deviate/prompts/commands/deviate-shard.md +226 -0
- deviate/prompts/commands/deviate-tasks.md +281 -0
- deviate/prompts/commands/deviate-triage.md +141 -0
- deviate/prompts/constitution_seed.md +51 -0
- deviate/prompts/core/core.md +21 -0
- deviate/prompts/core/macro-auto.md +59 -0
- deviate/prompts/core/macro-command.md +54 -0
- deviate/prompts/core/macro-skill.md +54 -0
- deviate/prompts/core/meso-auto.md +63 -0
- deviate/prompts/core/meso-command.md +58 -0
- deviate/prompts/core/meso-skill.md +58 -0
- deviate/prompts/core/micro-auto.md +76 -0
- deviate/prompts/core/micro-command.md +67 -0
- deviate/prompts/core/micro-skill.md +67 -0
- deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
- deviate/prompts/governance/__init__.py +0 -0
- deviate/prompts/governance/agents_seed.md +1 -0
- deviate/prompts/governance/claudemd_seed.md +1 -0
- deviate/prompts/governance/graphite_seed.md +18 -0
- deviate/prompts/governance/libref_seed.md +3 -0
- deviate/state/__init__.py +0 -0
- deviate/state/config.py +257 -0
- deviate/state/ledger.py +407 -0
- deviate/ui/__init__.py +5 -0
- deviate/ui/monitor.py +187 -0
- 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)
|