stargate-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stargate/__init__.py +0 -0
- stargate/__main__.py +3 -0
- stargate/agent.py +194 -0
- stargate/agents.yaml +148 -0
- stargate/cli.py +286 -0
- stargate/commit.py +253 -0
- stargate/config.py +422 -0
- stargate/core.py +341 -0
- stargate/detect.py +140 -0
- stargate/doctor.py +407 -0
- stargate/fanout.py +1152 -0
- stargate/prompts/architect.md +28 -0
- stargate/prompts/developer.md +27 -0
- stargate/prompts/fanout.md +44 -0
- stargate/prompts/fixer.md +35 -0
- stargate/prompts/reviewer.md +86 -0
- stargate/publish.py +105 -0
- stargate/run.py +847 -0
- stargate/source.py +66 -0
- stargate/stages.py +1131 -0
- stargate_cli-1.0.0.dist-info/METADATA +1427 -0
- stargate_cli-1.0.0.dist-info/RECORD +26 -0
- stargate_cli-1.0.0.dist-info/WHEEL +5 -0
- stargate_cli-1.0.0.dist-info/entry_points.txt +2 -0
- stargate_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- stargate_cli-1.0.0.dist-info/top_level.txt +1 -0
stargate/stages.py
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
"""The workflow itself: architect, developer, the review loop, and the tests
|
|
2
|
+
and summary that bracket them."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .agent import invoke_agent
|
|
13
|
+
from .commit import commit_run, commit_summary, terminal_commit_at_head
|
|
14
|
+
from .config import (
|
|
15
|
+
SEVERITIES,
|
|
16
|
+
blocking_severities,
|
|
17
|
+
commit_enabled,
|
|
18
|
+
prompt_dirs,
|
|
19
|
+
render_prompt,
|
|
20
|
+
token_cap,
|
|
21
|
+
validate_publication_request,
|
|
22
|
+
)
|
|
23
|
+
from .core import (
|
|
24
|
+
RunContext,
|
|
25
|
+
StargateError,
|
|
26
|
+
git,
|
|
27
|
+
print_output,
|
|
28
|
+
repo_root,
|
|
29
|
+
run_process,
|
|
30
|
+
split_plan_name,
|
|
31
|
+
)
|
|
32
|
+
from .detect import detection_mode, selected_test_command
|
|
33
|
+
from .publish import (
|
|
34
|
+
PUBLISH_FAILED,
|
|
35
|
+
open_pull_request,
|
|
36
|
+
publication_command,
|
|
37
|
+
push_branch,
|
|
38
|
+
select_remote,
|
|
39
|
+
)
|
|
40
|
+
from .run import (
|
|
41
|
+
budget_spent,
|
|
42
|
+
complete_stage,
|
|
43
|
+
create_worktree,
|
|
44
|
+
enter_stage,
|
|
45
|
+
inherited_findings,
|
|
46
|
+
load_run,
|
|
47
|
+
make_context,
|
|
48
|
+
save_state,
|
|
49
|
+
snapshot,
|
|
50
|
+
unique_branch,
|
|
51
|
+
untracked_entries,
|
|
52
|
+
warn_if_dirty,
|
|
53
|
+
worktree_fingerprint,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
TEST_TAIL_LINES = 200
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _task_output_label(ctx: RunContext, phase: str) -> str | None:
|
|
60
|
+
if ctx.mode == "fanout-task":
|
|
61
|
+
return f"task {ctx.slug}/{phase}"
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _print_test_output(ctx: RunContext, output: str) -> None:
|
|
66
|
+
"""Keep a parallel task's complete test transcript atomic and attributable."""
|
|
67
|
+
label = _task_output_label(ctx, "tests")
|
|
68
|
+
if label is None:
|
|
69
|
+
print_output(output, end="" if output.endswith("\n") else "\n")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
prefix = f"[{label}] "
|
|
73
|
+
chunks = output.splitlines(keepends=True)
|
|
74
|
+
labelled = "".join(prefix + chunk for chunk in chunks) if chunks else f"[{label}]"
|
|
75
|
+
print_output(labelled, end="" if labelled.endswith("\n") else "\n")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def plan_tests(ctx: RunContext) -> None:
|
|
79
|
+
"""Choose once from the original repository and explain the choice."""
|
|
80
|
+
settings = ctx.config.get("settings", {})
|
|
81
|
+
mode = detection_mode(ctx.config)
|
|
82
|
+
configured = str(settings.get("test_command", "") or "").strip()
|
|
83
|
+
ctx.test_command, ctx.detected = selected_test_command(ctx.config, ctx.repo)
|
|
84
|
+
ctx.test_source = ""
|
|
85
|
+
if configured:
|
|
86
|
+
ctx.test_source = "settings.test_command"
|
|
87
|
+
print(f"Tests: {configured} (settings.test_command)")
|
|
88
|
+
return
|
|
89
|
+
if mode == "off":
|
|
90
|
+
ctx.test_source = "not configured; detection off"
|
|
91
|
+
print("Tests: none configured (test command detection is off)")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if not ctx.detected:
|
|
95
|
+
ctx.test_source = "not configured; none detected"
|
|
96
|
+
print("Tests: none configured, none detected")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
selected = ctx.detected[0]
|
|
100
|
+
if mode == "auto":
|
|
101
|
+
ctx.test_source = f"detected: {selected.source}"
|
|
102
|
+
print(
|
|
103
|
+
f"Tests: {selected.command} (detected: {selected.source}; "
|
|
104
|
+
"automatic)"
|
|
105
|
+
)
|
|
106
|
+
for candidate in ctx.detected[1:]:
|
|
107
|
+
print(
|
|
108
|
+
f" {candidate.command} (detected: {candidate.source}; "
|
|
109
|
+
"lower priority, not selected)"
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
ctx.test_source = (
|
|
114
|
+
f"detected {selected.command} from {selected.source}; report-only"
|
|
115
|
+
)
|
|
116
|
+
print(
|
|
117
|
+
f"Tests: {selected.command} (detected: {selected.source}; not run -- "
|
|
118
|
+
"settings.test_command_detection: report)"
|
|
119
|
+
)
|
|
120
|
+
for candidate in ctx.detected[1:]:
|
|
121
|
+
print(
|
|
122
|
+
f" {candidate.command} (detected: {candidate.source}; not run)"
|
|
123
|
+
)
|
|
124
|
+
print(" To confirm the first candidate, add to .stargate.yaml:")
|
|
125
|
+
print(" settings:")
|
|
126
|
+
print(f" test_command: {selected.command!r}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_tests(ctx: RunContext, label: str) -> tuple[int | None, str]:
|
|
130
|
+
"""Run the configured suite. Returns (exit code, report shown to agents)."""
|
|
131
|
+
settings = ctx.config.get("settings", {})
|
|
132
|
+
command = ctx.test_command
|
|
133
|
+
output_label = _task_output_label(ctx, "tests")
|
|
134
|
+
output_prefix = f"[{output_label}] " if output_label else ""
|
|
135
|
+
if not command:
|
|
136
|
+
print_output(
|
|
137
|
+
"\n"
|
|
138
|
+
f"{output_prefix}No automatic test_command configured; "
|
|
139
|
+
"skipping orchestrator-level tests."
|
|
140
|
+
)
|
|
141
|
+
if ctx.detected:
|
|
142
|
+
candidate = ctx.detected[0]
|
|
143
|
+
return None, (
|
|
144
|
+
"No test command is configured in the orchestrator. It detected "
|
|
145
|
+
f"`{candidate.command}` ({candidate.source}) but did not run it. "
|
|
146
|
+
"Run the project's own tests yourself if you can."
|
|
147
|
+
)
|
|
148
|
+
return None, (
|
|
149
|
+
"No test command is configured in the orchestrator. "
|
|
150
|
+
"Run the project's own tests yourself if you can."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
kind = "detected" if ctx.test_source.startswith("detected:") else "configured"
|
|
154
|
+
print_output(f"\n{output_prefix}Running {kind} test command: {command}")
|
|
155
|
+
timeout = float(settings.get("test_timeout_seconds", 900)) or None
|
|
156
|
+
before = untracked_entries(ctx.worktree)
|
|
157
|
+
try:
|
|
158
|
+
artifact = ctx.artifacts / f"tests-{label}.txt"
|
|
159
|
+
proc = run_process(
|
|
160
|
+
["/bin/sh", "-lc", command],
|
|
161
|
+
ctx.worktree,
|
|
162
|
+
check=False,
|
|
163
|
+
timeout=timeout,
|
|
164
|
+
log_path=artifact,
|
|
165
|
+
timeout_is_error=False,
|
|
166
|
+
output_label=output_label,
|
|
167
|
+
)
|
|
168
|
+
output, code = proc.stdout or "", proc.returncode
|
|
169
|
+
if code == 124:
|
|
170
|
+
output += f"\n\n[timed out after {timeout}s]"
|
|
171
|
+
finally:
|
|
172
|
+
# Test suites commonly leave .venv/, *.egg-info/, target/ or
|
|
173
|
+
# node_modules/. A tidy project ignores them, but an incomplete
|
|
174
|
+
# .gitignore must not turn build output into history. Tracked rewrites
|
|
175
|
+
# remain eligible because they are part of the diff the reviewer saw.
|
|
176
|
+
# Persisting these names matters when a crash and resume span processes.
|
|
177
|
+
ctx.test_artifacts |= untracked_entries(ctx.worktree) - before
|
|
178
|
+
save_state(ctx, "running")
|
|
179
|
+
|
|
180
|
+
_print_test_output(ctx, output)
|
|
181
|
+
artifact.write_text(
|
|
182
|
+
f"$ {command}\n\n{output}\n\nexit_code={code}\n"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Agents only need the tail; a full suite log blows the prompt budget.
|
|
186
|
+
tail = "\n".join(output.splitlines()[-TEST_TAIL_LINES:])
|
|
187
|
+
verdict = "PASSED" if code == 0 else f"FAILED (exit {code})"
|
|
188
|
+
return code, f"$ {command}\n{verdict}\n\n{tail}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def finish(
|
|
192
|
+
ctx: RunContext,
|
|
193
|
+
task: str,
|
|
194
|
+
verdict: str,
|
|
195
|
+
test_exit: int | None,
|
|
196
|
+
*,
|
|
197
|
+
commit: bool,
|
|
198
|
+
publish: bool = False,
|
|
199
|
+
) -> int:
|
|
200
|
+
if commit:
|
|
201
|
+
needs_terminal_commit = False
|
|
202
|
+
if ctx.mode == "fanout":
|
|
203
|
+
terminal_commit = str(ctx.fanout.get("terminal_commit") or "")
|
|
204
|
+
terminal_verdict = str(ctx.fanout.get("terminal_verdict") or "")
|
|
205
|
+
if terminal_verdict != verdict or not terminal_commit:
|
|
206
|
+
recovered = terminal_commit_at_head(ctx, verdict)
|
|
207
|
+
if recovered:
|
|
208
|
+
ctx.commit = recovered
|
|
209
|
+
ctx.fanout["terminal_commit"] = recovered
|
|
210
|
+
ctx.fanout["terminal_verdict"] = verdict
|
|
211
|
+
else:
|
|
212
|
+
needs_terminal_commit = True
|
|
213
|
+
commit_outcome = commit_run(
|
|
214
|
+
ctx, verdict, test_exit, allow_empty=needs_terminal_commit
|
|
215
|
+
)
|
|
216
|
+
if ctx.mode == "fanout" and commit_outcome == "committed":
|
|
217
|
+
ctx.fanout["terminal_commit"] = ctx.commit
|
|
218
|
+
ctx.fanout["terminal_verdict"] = verdict
|
|
219
|
+
else:
|
|
220
|
+
# A failed commit belongs to the invocation that attempted it. If the
|
|
221
|
+
# user deliberately resumes without committing, preserving that error
|
|
222
|
+
# would make a successful terminal result keep returning exit 5.
|
|
223
|
+
ctx.commit_error = ""
|
|
224
|
+
commit_outcome = "disabled"
|
|
225
|
+
if commit_outcome != "failed":
|
|
226
|
+
# The run actually finished, so its recorded review/fix cycle stops
|
|
227
|
+
# being a checkpoint. Resuming a finished run is a deliberate request
|
|
228
|
+
# for a fresh verdict -- that is how a corrected reviewer config takes
|
|
229
|
+
# effect. Only a run still blocked on its commit keeps the record.
|
|
230
|
+
ctx.review = {}
|
|
231
|
+
elif ctx.review:
|
|
232
|
+
# Committing stages the tree on its way out, and a staged path leaves
|
|
233
|
+
# the untracked half of the fingerprint. Rebase the record on what is
|
|
234
|
+
# on disk now, so a resume can still tell the reviewed tree from one
|
|
235
|
+
# edited since the verdict.
|
|
236
|
+
ctx.review = {**ctx.review, "fingerprint": worktree_fingerprint(ctx)}
|
|
237
|
+
write_summary(ctx, task, verdict, test_exit, commit)
|
|
238
|
+
save_state(ctx, verdict.lower())
|
|
239
|
+
|
|
240
|
+
pushed, published, publish_exit = (
|
|
241
|
+
_publish(ctx, verdict, test_exit, commit_outcome) if publish else ("", "", None)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
print("\n=== RESULT ===")
|
|
245
|
+
print(f"Verdict: {verdict}")
|
|
246
|
+
print(f"Branch: {ctx.branch}")
|
|
247
|
+
print(f"Commit: {_result_commit_summary(ctx, commit)}")
|
|
248
|
+
print(f"Worktree: {ctx.worktree}")
|
|
249
|
+
if ctx.mode == "fanout":
|
|
250
|
+
print("Review/fix: integration worktree only")
|
|
251
|
+
print(f"Artifacts: {ctx.artifacts}")
|
|
252
|
+
if published.strip():
|
|
253
|
+
print(f"Pull request: {published.strip()}")
|
|
254
|
+
if ctx.tokens_used:
|
|
255
|
+
cap = token_cap(ctx.config)
|
|
256
|
+
print(f"Tokens: {ctx.tokens_used:,}" + (f" of {cap:,}" if cap else " (no cap)"))
|
|
257
|
+
if ctx.mode == "fanout":
|
|
258
|
+
if ctx.commit:
|
|
259
|
+
print(
|
|
260
|
+
"\nFinal review and fixer passes ran only on the Stargate "
|
|
261
|
+
"integration branch; fixer edits were not copied back to task "
|
|
262
|
+
"branches.\nTask branches were merged only into the Stargate "
|
|
263
|
+
"integration branch.\n",
|
|
264
|
+
end="",
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
task_commits = _fanout_task_commit_count(ctx)
|
|
268
|
+
noun = "commit" if task_commits == 1 else "commits"
|
|
269
|
+
verb = "remains" if task_commits == 1 else "remain"
|
|
270
|
+
print(
|
|
271
|
+
"\nNo integration terminal commit was created; "
|
|
272
|
+
f"{task_commits} completed task {noun} {verb} on the task branches.\n",
|
|
273
|
+
end="",
|
|
274
|
+
)
|
|
275
|
+
elif publish and not ctx.commit:
|
|
276
|
+
print("\nNothing was committed.")
|
|
277
|
+
if pushed:
|
|
278
|
+
print(
|
|
279
|
+
f"\nThe run's branch was pushed to {pushed}; nothing was merged into "
|
|
280
|
+
"your original branch or deleted automatically."
|
|
281
|
+
)
|
|
282
|
+
elif publish:
|
|
283
|
+
# A failed or interrupted push may have reached the remote before its
|
|
284
|
+
# acknowledgement was lost; do not claim that nothing was pushed.
|
|
285
|
+
print("\nNothing was merged into your original branch or deleted automatically.")
|
|
286
|
+
elif ctx.mode == "fanout":
|
|
287
|
+
print(
|
|
288
|
+
"Nothing was merged into your original branch, pushed, or deleted "
|
|
289
|
+
"automatically."
|
|
290
|
+
)
|
|
291
|
+
elif ctx.commit:
|
|
292
|
+
print("\nNothing was merged, pushed, or deleted automatically.")
|
|
293
|
+
else:
|
|
294
|
+
print("\nNothing was committed, merged, pushed, or deleted automatically.")
|
|
295
|
+
print(
|
|
296
|
+
f"Inspect with: cd {shlex.quote(str(ctx.worktree))} "
|
|
297
|
+
f"&& git status && git diff {shlex.quote(ctx.base_commit)}"
|
|
298
|
+
)
|
|
299
|
+
if ctx.commit:
|
|
300
|
+
print(
|
|
301
|
+
"History: git log --oneline "
|
|
302
|
+
f"{shlex.quote(ctx.base_commit + '..' + ctx.branch)}"
|
|
303
|
+
)
|
|
304
|
+
print(
|
|
305
|
+
"Build on it with: stargate run --base-ref "
|
|
306
|
+
f"{shlex.quote(ctx.branch)} \"<next task>\""
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if publish_exit is not None:
|
|
310
|
+
return publish_exit
|
|
311
|
+
if commit_outcome == "failed":
|
|
312
|
+
return 5
|
|
313
|
+
if verdict == "BUDGET_EXCEEDED":
|
|
314
|
+
return 4
|
|
315
|
+
if verdict != "APPROVED":
|
|
316
|
+
return 2
|
|
317
|
+
if test_exit not in (None, 0):
|
|
318
|
+
return 3
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def pull_request_body(ctx: RunContext, verdict: str, test_exit: int | None) -> str:
|
|
323
|
+
if ctx.test_command:
|
|
324
|
+
result = f"exit {test_exit}" if test_exit is not None else "not run"
|
|
325
|
+
tests = f"{ctx.test_command} ({result})"
|
|
326
|
+
else:
|
|
327
|
+
tests = "not run (report-only detection)" if "report-only" in ctx.test_source else (
|
|
328
|
+
"not configured"
|
|
329
|
+
)
|
|
330
|
+
source = f"- **Task source:** {ctx.task_source}\n" if ctx.task_source else ""
|
|
331
|
+
findings = "\n".join(findings_table(ctx.findings)) if ctx.findings else (
|
|
332
|
+
"The reviewer reported no findings."
|
|
333
|
+
)
|
|
334
|
+
# A bullet per field: single newlines would render as one run-on
|
|
335
|
+
# paragraph, and the body is the surface the findings are read on.
|
|
336
|
+
return f"""- **Verdict:** {verdict}
|
|
337
|
+
- **Run:** {ctx.run_id}
|
|
338
|
+
- **Branch:** {ctx.branch}
|
|
339
|
+
- **Base:** {ctx.base_ref} @ {ctx.base_commit[:12]}
|
|
340
|
+
- **Commit:** {ctx.commit or 'none'}
|
|
341
|
+
{source}- **Tests:** {tests}
|
|
342
|
+
|
|
343
|
+
## task
|
|
344
|
+
{ctx.task}
|
|
345
|
+
|
|
346
|
+
## findings
|
|
347
|
+
{findings}
|
|
348
|
+
|
|
349
|
+
Produced by stargate agents; publication requested by the operator.
|
|
350
|
+
Traces: .stargate/runs/{ctx.run_id}/
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _publish(
|
|
355
|
+
ctx: RunContext, verdict: str, test_exit: int | None, commit_outcome: str,
|
|
356
|
+
) -> tuple[str, str, int | None]:
|
|
357
|
+
"""Publication failure must leave the terminal verdict and commit intact."""
|
|
358
|
+
print("\n=== PULL REQUEST ===")
|
|
359
|
+
# D20: use task text, never the architect's NAME or parsed source fields.
|
|
360
|
+
first_line = next((line for line in ctx.task.splitlines() if line.strip()), "")
|
|
361
|
+
title = " ".join(first_line.split()) or "stargate run"
|
|
362
|
+
cmd: list[str] = []
|
|
363
|
+
body_path = ctx.artifacts / "pull-request.md"
|
|
364
|
+
remote, pushed = "<remote>", ""
|
|
365
|
+
reason = (
|
|
366
|
+
verdict if verdict != "APPROVED" else
|
|
367
|
+
f"the test command failed (exit {test_exit})" if test_exit not in (None, 0) else
|
|
368
|
+
"the commit failed" if commit_outcome == "failed" else
|
|
369
|
+
"nothing was committed" if not ctx.commit else ""
|
|
370
|
+
)
|
|
371
|
+
publish_exit = None
|
|
372
|
+
try:
|
|
373
|
+
body = pull_request_body(ctx, verdict, test_exit)
|
|
374
|
+
body_path.write_text(body)
|
|
375
|
+
cmd = publication_command(ctx.config, ctx.branch, title)
|
|
376
|
+
if reason:
|
|
377
|
+
print(
|
|
378
|
+
f"Not published: {reason}. "
|
|
379
|
+
"Publishing requires an APPROVED result and passing tests."
|
|
380
|
+
)
|
|
381
|
+
try:
|
|
382
|
+
remote = select_remote(ctx.repo)
|
|
383
|
+
except StargateError:
|
|
384
|
+
pass # The manual instructions can leave remote selection to the operator.
|
|
385
|
+
else:
|
|
386
|
+
remote = select_remote(ctx.repo)
|
|
387
|
+
push_branch(ctx.repo, remote, ctx.branch, ctx.artifacts)
|
|
388
|
+
pushed = remote
|
|
389
|
+
output = open_pull_request(ctx.config, ctx.repo, cmd, body)
|
|
390
|
+
return pushed, output.strip() or "Opened successfully.", None
|
|
391
|
+
except (StargateError, OSError, ValueError, KeyboardInterrupt) as exc:
|
|
392
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
393
|
+
if pushed:
|
|
394
|
+
print(
|
|
395
|
+
f"The branch is already on {pushed}; it has not been rolled back.", file=sys.stderr,
|
|
396
|
+
)
|
|
397
|
+
publish_exit = (
|
|
398
|
+
128 + int(getattr(exc, "signum", 2)) if isinstance(exc, KeyboardInterrupt)
|
|
399
|
+
else PUBLISH_FAILED if not reason else None
|
|
400
|
+
)
|
|
401
|
+
print(f"The work is on branch {ctx.branch} in {ctx.worktree}.")
|
|
402
|
+
if not cmd:
|
|
403
|
+
print(f"No PR command could be prepared; see the error above. PR body path: {body_path}")
|
|
404
|
+
return pushed, "", publish_exit
|
|
405
|
+
print("To publish it yourself:")
|
|
406
|
+
print(f" cd {shlex.quote(str(ctx.repo))}")
|
|
407
|
+
if not pushed:
|
|
408
|
+
print(f" git push {shlex.quote(remote)} {shlex.quote(ctx.branch)}")
|
|
409
|
+
print(f" {shlex.join(cmd)} < {shlex.quote(str(body_path))}")
|
|
410
|
+
return pushed, "", publish_exit
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def write_summary(
|
|
414
|
+
ctx: RunContext,
|
|
415
|
+
task: str,
|
|
416
|
+
verdict: str,
|
|
417
|
+
test_exit: int | None,
|
|
418
|
+
commit: bool,
|
|
419
|
+
) -> None:
|
|
420
|
+
if ctx.worktree.exists():
|
|
421
|
+
try:
|
|
422
|
+
status = git(ctx.worktree, "status", "--short").stdout
|
|
423
|
+
diff_stat = git(
|
|
424
|
+
ctx.worktree, "diff", "--stat", ctx.base_commit
|
|
425
|
+
).stdout
|
|
426
|
+
except StargateError as exc:
|
|
427
|
+
status = f"(Git status unavailable: {exc})"
|
|
428
|
+
diff_stat = "(Git diff unavailable)"
|
|
429
|
+
else:
|
|
430
|
+
status = "(integration worktree not created)"
|
|
431
|
+
diff_stat = "(integration worktree not created)"
|
|
432
|
+
cap = token_cap(ctx.config)
|
|
433
|
+
tokens = f"{ctx.tokens_used:,}" + (f" of {cap:,}" if cap else "")
|
|
434
|
+
fanout_details = ""
|
|
435
|
+
if ctx.mode == "fanout":
|
|
436
|
+
fanout_details = (
|
|
437
|
+
"Mode: fan-out\n"
|
|
438
|
+
"Review/fixer scope: integration worktree only; fixer edits are "
|
|
439
|
+
"not copied back to task branches.\n"
|
|
440
|
+
)
|
|
441
|
+
source_line = f"Task source: {ctx.task_source}\n" if ctx.task_source else ""
|
|
442
|
+
summary = f"""# stargate run
|
|
443
|
+
|
|
444
|
+
Task: {task}
|
|
445
|
+
{source_line}Run: {ctx.run_id}
|
|
446
|
+
Base ref: {ctx.base_ref}
|
|
447
|
+
Base commit: {ctx.base_commit}
|
|
448
|
+
Branch: {ctx.branch}
|
|
449
|
+
Commit: {_result_commit_summary(ctx, commit)}
|
|
450
|
+
Worktree: {ctx.worktree}
|
|
451
|
+
{fanout_details}Verdict: {verdict}
|
|
452
|
+
Test command: {ctx.test_command or "(none)"} ({ctx.test_source})
|
|
453
|
+
Test exit: {test_exit}
|
|
454
|
+
Tokens reported: {tokens}
|
|
455
|
+
|
|
456
|
+
## git status
|
|
457
|
+
|
|
458
|
+
{status or "(clean)"}
|
|
459
|
+
|
|
460
|
+
## diff stat
|
|
461
|
+
|
|
462
|
+
{diff_stat or "(no tracked diff)"}
|
|
463
|
+
"""
|
|
464
|
+
if ctx.findings:
|
|
465
|
+
summary += "\n## findings\n\n" + "\n".join(findings_table(ctx.findings)) + "\n"
|
|
466
|
+
if ctx.mode == "fanout":
|
|
467
|
+
saved_records = ctx.fanout.get("tasks", {})
|
|
468
|
+
records = saved_records if isinstance(saved_records, dict) else {}
|
|
469
|
+
lines = [
|
|
470
|
+
"\n## fan-out tasks\n",
|
|
471
|
+
"| task | status | tests | tokens | commit | error |",
|
|
472
|
+
"|---|---|---:|---:|---|---|",
|
|
473
|
+
]
|
|
474
|
+
for task_id in ctx.fanout.get("order", []):
|
|
475
|
+
saved_record = records.get(task_id)
|
|
476
|
+
if not isinstance(saved_record, dict):
|
|
477
|
+
lines.append(
|
|
478
|
+
f"| {_summary_cell(task_id)} | missing | not run | - | - | "
|
|
479
|
+
"Task record is missing from state.json. |"
|
|
480
|
+
)
|
|
481
|
+
continue
|
|
482
|
+
record = saved_record
|
|
483
|
+
test_exit_value = record.get("test_exit")
|
|
484
|
+
tests = "not run" if test_exit_value is None else str(test_exit_value)
|
|
485
|
+
reported = _summary_tokens(record.get("tokens_used"))
|
|
486
|
+
task_commit = str(record.get("commit") or "")
|
|
487
|
+
error = str(record.get("error") or "-")
|
|
488
|
+
lines.append(
|
|
489
|
+
f"| {_summary_cell(task_id)} | "
|
|
490
|
+
f"{_summary_cell(record.get('status', 'unknown'))} | "
|
|
491
|
+
f"{_summary_cell(tests)} | {_summary_cell(reported)} | "
|
|
492
|
+
f"{_summary_cell(task_commit[:12] or '-')} | "
|
|
493
|
+
f"{_summary_cell(error)} |"
|
|
494
|
+
)
|
|
495
|
+
summary += "\n".join(lines) + "\n"
|
|
496
|
+
(ctx.artifacts / "summary.md").write_text(summary)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def findings_table(findings: list[Any]) -> list[str]:
|
|
500
|
+
lines = ["| severity | where | finding |", "|---|---|---|"]
|
|
501
|
+
for entry in _by_severity(findings):
|
|
502
|
+
if not isinstance(entry, dict):
|
|
503
|
+
# Only reachable from hand-edited or externally written state.
|
|
504
|
+
# Showing it beats raising while writing the terminal report.
|
|
505
|
+
lines.append(f"| ? | - | {_summary_cell(entry)} |")
|
|
506
|
+
continue
|
|
507
|
+
where = str(entry.get("file") or "-")
|
|
508
|
+
if entry.get("line") is not None:
|
|
509
|
+
where += f":{entry['line']}"
|
|
510
|
+
detail = str(entry.get("finding", ""))
|
|
511
|
+
if entry.get("why"):
|
|
512
|
+
detail += f" (why: {entry['why']})"
|
|
513
|
+
lines.append(
|
|
514
|
+
f"| {_summary_cell(entry.get('severity', '?'))} | "
|
|
515
|
+
f"{_summary_cell(where)} | {_summary_cell(detail)} |"
|
|
516
|
+
)
|
|
517
|
+
return lines
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _fanout_task_commit_count(ctx: RunContext) -> int:
|
|
521
|
+
saved_records = ctx.fanout.get("tasks", {})
|
|
522
|
+
if not isinstance(saved_records, dict):
|
|
523
|
+
return 0
|
|
524
|
+
return sum(
|
|
525
|
+
isinstance(record, dict)
|
|
526
|
+
and isinstance(record.get("commit"), str)
|
|
527
|
+
and bool(record["commit"])
|
|
528
|
+
for record in saved_records.values()
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _result_commit_summary(ctx: RunContext, enabled: bool) -> str:
|
|
533
|
+
if ctx.mode != "fanout" or enabled:
|
|
534
|
+
return commit_summary(ctx, enabled)
|
|
535
|
+
task_commits = _fanout_task_commit_count(ctx)
|
|
536
|
+
noun = "commit" if task_commits == 1 else "commits"
|
|
537
|
+
return (
|
|
538
|
+
"none (no integration terminal commit; "
|
|
539
|
+
f"{task_commits} task {noun} preserved)"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _by_severity(findings: list[Any]) -> list[Any]:
|
|
544
|
+
"""Highest severity first, reviewer order kept inside each severity.
|
|
545
|
+
|
|
546
|
+
Total by construction. Parsing guarantees the shape for anything this run
|
|
547
|
+
produced, but `findings` is restored straight from state.json, and a
|
|
548
|
+
corrupted or externally written entry must not raise here -- this runs
|
|
549
|
+
while writing the terminal report, after the commit has been attempted.
|
|
550
|
+
"""
|
|
551
|
+
def rank(entry: Any) -> int:
|
|
552
|
+
severity = entry.get("severity") if isinstance(entry, dict) else None
|
|
553
|
+
return (
|
|
554
|
+
SEVERITIES.index(severity)
|
|
555
|
+
if severity in SEVERITIES
|
|
556
|
+
else len(SEVERITIES)
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
return sorted(findings, key=rank)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _known_findings_section(ctx: RunContext) -> str:
|
|
563
|
+
"""Include the previous review's context, or leave the prompt unchanged."""
|
|
564
|
+
run_id, findings = inherited_findings(ctx.repo, ctx.base_ref)
|
|
565
|
+
if not findings:
|
|
566
|
+
return ""
|
|
567
|
+
lines = [
|
|
568
|
+
"",
|
|
569
|
+
"## Known unresolved findings",
|
|
570
|
+
"",
|
|
571
|
+
f"These are the findings of the last completed review of run {run_id}, "
|
|
572
|
+
f"whose branch {ctx.base_ref} is this run's base ref.",
|
|
573
|
+
"",
|
|
574
|
+
"They describe the tree that review saw, not necessarily the tree you "
|
|
575
|
+
"are reading: an edit made after it -- a fixer pass that ran before the "
|
|
576
|
+
"run stopped -- may already have resolved one. Nothing here was "
|
|
577
|
+
"re-checked. Verify each against the current code, then plan the fix or "
|
|
578
|
+
"say why it stays.",
|
|
579
|
+
"",
|
|
580
|
+
]
|
|
581
|
+
for entry in _by_severity(findings):
|
|
582
|
+
if not isinstance(entry, dict):
|
|
583
|
+
# Hand-edited state may contain junk; report it without failing.
|
|
584
|
+
lines.append("- [?] " + str(entry).replace("\n", " "))
|
|
585
|
+
continue
|
|
586
|
+
# `file` and `line` are independently optional, so a line without a
|
|
587
|
+
# file is valid data. Dropping it here would hide it, and the summary
|
|
588
|
+
# table already renders that pair as `-:42`.
|
|
589
|
+
where = str(entry.get("file") or "")
|
|
590
|
+
if entry.get("line") is not None:
|
|
591
|
+
where = f"{where or '-'}:{entry['line']}"
|
|
592
|
+
detail = str(entry.get("finding", ""))
|
|
593
|
+
if entry.get("why"):
|
|
594
|
+
detail += f" (why: {entry['why']})"
|
|
595
|
+
severity = str(entry.get("severity", "?"))
|
|
596
|
+
lines.append(f"- [{severity}] " + (f"{where} -- " if where else "") + detail)
|
|
597
|
+
return "\n".join(lines) + "\n"
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _summary_cell(value: object) -> str:
|
|
601
|
+
"""Keep persisted diagnostic text inside one Markdown table cell."""
|
|
602
|
+
return str(value).replace("|", "\\|").replace("\r\n", "\n").replace(
|
|
603
|
+
"\n", "<br>"
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _summary_tokens(value: object) -> str:
|
|
608
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
609
|
+
return f"{value:,}"
|
|
610
|
+
return "-" if value is None else str(value)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _resume_context(
|
|
614
|
+
repo: Path,
|
|
615
|
+
args: argparse.Namespace,
|
|
616
|
+
config: dict[str, Any],
|
|
617
|
+
) -> RunContext:
|
|
618
|
+
"""Load resume state once mode has been validated for safe dispatch."""
|
|
619
|
+
state_path = repo / ".stargate" / "runs" / args.run_id / "state.json"
|
|
620
|
+
if not state_path.exists():
|
|
621
|
+
raise StargateError(f"No run state at {state_path}")
|
|
622
|
+
try:
|
|
623
|
+
state = json.loads(state_path.read_text())
|
|
624
|
+
except (OSError, ValueError) as exc:
|
|
625
|
+
raise StargateError(
|
|
626
|
+
f"Cannot resume {args.run_id}: unreadable state.json ({exc})"
|
|
627
|
+
) from exc
|
|
628
|
+
if not isinstance(state, dict):
|
|
629
|
+
raise StargateError(
|
|
630
|
+
f"Cannot resume {args.run_id}: state.json is not a JSON object."
|
|
631
|
+
)
|
|
632
|
+
# Runs written before mode was persisted were necessarily linear. Keep
|
|
633
|
+
# those histories resumable while still rejecting an explicit bad mode.
|
|
634
|
+
mode = state["mode"] if "mode" in state else "linear"
|
|
635
|
+
if mode not in ("linear", "fanout"):
|
|
636
|
+
raise StargateError(
|
|
637
|
+
f"Cannot resume {args.run_id}: state.json has no valid mode "
|
|
638
|
+
"(expected 'linear' or 'fanout')."
|
|
639
|
+
)
|
|
640
|
+
try:
|
|
641
|
+
return load_run(
|
|
642
|
+
repo, args.run_id, config, use_frozen=args.config is None
|
|
643
|
+
)
|
|
644
|
+
except (OSError, TypeError, ValueError, KeyError) as exc:
|
|
645
|
+
raise StargateError(
|
|
646
|
+
f"Cannot resume {args.run_id}: invalid state.json ({exc})"
|
|
647
|
+
) from exc
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _write_failed_fanout_summary(ctx: RunContext | None) -> None:
|
|
651
|
+
"""Leave a failed fan-out run the summary artifact every other run gets."""
|
|
652
|
+
if ctx is None:
|
|
653
|
+
# The failure preceded orchestration, so no run is ours to describe.
|
|
654
|
+
return
|
|
655
|
+
try:
|
|
656
|
+
write_summary(ctx, ctx.task, "FAILED", None, True)
|
|
657
|
+
except (StargateError, OSError, TypeError, ValueError, KeyError):
|
|
658
|
+
# The original orchestration failure remains the actionable error.
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def orchestrate(args: argparse.Namespace, script_dir: Path, config: dict[str, Any]) -> int:
|
|
663
|
+
repo = repo_root(Path.cwd())
|
|
664
|
+
resuming = args.command == "resume"
|
|
665
|
+
|
|
666
|
+
fanout = bool(getattr(args, "fan_out", False))
|
|
667
|
+
ctx = None
|
|
668
|
+
if resuming:
|
|
669
|
+
ctx = _resume_context(repo, args, config)
|
|
670
|
+
fanout = ctx.mode == "fanout"
|
|
671
|
+
effective_config = ctx.config if ctx is not None else config
|
|
672
|
+
validate_publication_request(effective_config, requested=getattr(args, "pr", False))
|
|
673
|
+
if fanout:
|
|
674
|
+
from .fanout import orchestrate_fanout
|
|
675
|
+
|
|
676
|
+
orchestrated: list[RunContext] = []
|
|
677
|
+
try:
|
|
678
|
+
return orchestrate_fanout(
|
|
679
|
+
args, script_dir, config, repo, on_context=orchestrated.append
|
|
680
|
+
)
|
|
681
|
+
except StargateError:
|
|
682
|
+
_write_failed_fanout_summary(
|
|
683
|
+
orchestrated[-1] if orchestrated else None
|
|
684
|
+
)
|
|
685
|
+
raise
|
|
686
|
+
|
|
687
|
+
if resuming:
|
|
688
|
+
assert ctx is not None
|
|
689
|
+
prompts = [ctx.artifacts / "prompts"]
|
|
690
|
+
print(f"\nResuming {ctx.run_id}: {ctx.task}")
|
|
691
|
+
if redo := set(args.redo):
|
|
692
|
+
# Completion is the only reason a stage is skipped, so forgetting
|
|
693
|
+
# that record replaces hand-editing state.json.
|
|
694
|
+
ctx.done -= redo
|
|
695
|
+
print(f"Redoing: {', '.join(sorted(redo))}")
|
|
696
|
+
if "architect" in redo and "developer" in ctx.done:
|
|
697
|
+
print(
|
|
698
|
+
"Warning: the developer is still marked complete, so the "
|
|
699
|
+
"new plan will not be implemented. Add --redo developer.",
|
|
700
|
+
file=sys.stderr,
|
|
701
|
+
)
|
|
702
|
+
print(f"Completed: {', '.join(sorted(ctx.done)) or '(nothing)'}")
|
|
703
|
+
else:
|
|
704
|
+
blocking_severities(config)
|
|
705
|
+
warn_if_dirty(repo)
|
|
706
|
+
ctx = make_context(
|
|
707
|
+
repo, config, args.task, args.base_ref, args.name,
|
|
708
|
+
task_source=getattr(args, "from_url", None) or "",
|
|
709
|
+
)
|
|
710
|
+
prompts = snapshot(ctx, prompt_dirs(config, script_dir))
|
|
711
|
+
|
|
712
|
+
commit = commit_enabled(ctx.config) and not args.no_commit
|
|
713
|
+
blocking = blocking_severities(ctx.config)
|
|
714
|
+
|
|
715
|
+
print(f"\nRun ID: {ctx.run_id}")
|
|
716
|
+
print(f"Base: {ctx.base_ref} @ {ctx.base_commit[:12]}")
|
|
717
|
+
print(f"Branch: {ctx.branch}")
|
|
718
|
+
print(f"Worktree: {ctx.worktree}")
|
|
719
|
+
print(f"Artifacts:{ctx.artifacts}")
|
|
720
|
+
if blocking:
|
|
721
|
+
print(f"Review: blocking severities: {', '.join(blocking)}")
|
|
722
|
+
|
|
723
|
+
try:
|
|
724
|
+
plan_tests(ctx)
|
|
725
|
+
return run_stages(ctx, args, prompts, commit=commit)
|
|
726
|
+
except (StargateError, KeyboardInterrupt) as exc:
|
|
727
|
+
save_state(ctx, "failed", f"{type(exc).__name__}: {exc}")
|
|
728
|
+
print(f"\nResume with: stargate resume {ctx.run_id}", file=sys.stderr)
|
|
729
|
+
raise
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def run_stages(
|
|
733
|
+
ctx: RunContext,
|
|
734
|
+
args: argparse.Namespace,
|
|
735
|
+
prompts: list[Path],
|
|
736
|
+
*,
|
|
737
|
+
commit: bool,
|
|
738
|
+
) -> int:
|
|
739
|
+
publish = getattr(args, "pr", False)
|
|
740
|
+
plan_path = ctx.artifacts / "plan.md"
|
|
741
|
+
architect_ran = False
|
|
742
|
+
|
|
743
|
+
# 1. Architect reads the original repository and emits a plan.
|
|
744
|
+
if "architect" in ctx.done:
|
|
745
|
+
raw_plan = plan_path.read_text().strip()
|
|
746
|
+
print(f"\n=== ARCHITECT (skipped, reusing {plan_path}) ===")
|
|
747
|
+
else:
|
|
748
|
+
architect_ran = True
|
|
749
|
+
enter_stage(ctx, "architect")
|
|
750
|
+
architect_prompt = render_prompt(
|
|
751
|
+
prompts,
|
|
752
|
+
"architect",
|
|
753
|
+
task=ctx.task,
|
|
754
|
+
base_ref=ctx.base_ref,
|
|
755
|
+
known_findings=_known_findings_section(ctx),
|
|
756
|
+
)
|
|
757
|
+
print("\n=== ARCHITECT ===")
|
|
758
|
+
raw_plan = invoke_agent(
|
|
759
|
+
ctx, "architect", architect_prompt, ctx.repo, plan_path
|
|
760
|
+
).strip()
|
|
761
|
+
|
|
762
|
+
architect_name, plan = split_plan_name(raw_plan)
|
|
763
|
+
if not plan:
|
|
764
|
+
raise StargateError("Architect returned an empty plan.")
|
|
765
|
+
# The worktree is deliberately created after planning, so the branch can
|
|
766
|
+
# still adopt the architect's vocabulary. The run id was already printed
|
|
767
|
+
# and names artifacts/worktree paths; changing it here would break resume.
|
|
768
|
+
# An existing worktree is stronger evidence than a missing completion bit
|
|
769
|
+
# after a crash, and must never be orphaned by a rename.
|
|
770
|
+
if (
|
|
771
|
+
architect_name and not ctx.named_by_user and ctx.tag
|
|
772
|
+
and "worktree" not in ctx.done and not ctx.worktree.exists()
|
|
773
|
+
):
|
|
774
|
+
proposed = f"stargate/{architect_name}-{ctx.tag}"
|
|
775
|
+
ctx.branch = unique_branch(ctx.repo, proposed)
|
|
776
|
+
save_state(ctx, "running")
|
|
777
|
+
print(f"Branch: {ctx.branch} (named by the architect)")
|
|
778
|
+
if architect_ran:
|
|
779
|
+
complete_stage(ctx, "architect")
|
|
780
|
+
|
|
781
|
+
# 2. Create isolated implementation branch/worktree.
|
|
782
|
+
enter_stage(ctx, "worktree")
|
|
783
|
+
print("\n=== WORKTREE ===")
|
|
784
|
+
create_worktree(ctx)
|
|
785
|
+
complete_stage(ctx, "worktree")
|
|
786
|
+
|
|
787
|
+
if budget_spent(ctx, "the developer"):
|
|
788
|
+
return finish(ctx, ctx.task, "BUDGET_EXCEEDED", None, commit=commit, publish=publish)
|
|
789
|
+
|
|
790
|
+
# 3. Developer implements.
|
|
791
|
+
if "developer" in ctx.done:
|
|
792
|
+
print("\n=== DEVELOPER (skipped, already ran in this run) ===")
|
|
793
|
+
else:
|
|
794
|
+
enter_stage(ctx, "developer")
|
|
795
|
+
before = worktree_fingerprint(ctx)
|
|
796
|
+
developer_prompt = render_prompt(
|
|
797
|
+
prompts,
|
|
798
|
+
"developer",
|
|
799
|
+
task=ctx.task,
|
|
800
|
+
base_ref=ctx.base_ref,
|
|
801
|
+
plan=plan,
|
|
802
|
+
)
|
|
803
|
+
print("\n=== DEVELOPER ===")
|
|
804
|
+
invoke_agent(
|
|
805
|
+
ctx,
|
|
806
|
+
"developer",
|
|
807
|
+
developer_prompt,
|
|
808
|
+
ctx.worktree,
|
|
809
|
+
ctx.artifacts / "developer.txt",
|
|
810
|
+
)
|
|
811
|
+
if worktree_fingerprint(ctx) == before:
|
|
812
|
+
# Leaving this incomplete is intentional: a reviewer can only
|
|
813
|
+
# rediscover the empty diff, while resume must rerun this stage.
|
|
814
|
+
raise StargateError(
|
|
815
|
+
f"The developer changed nothing in {ctx.worktree}; there is "
|
|
816
|
+
f"nothing to review. Check the trace in "
|
|
817
|
+
f"{ctx.artifacts / 'developer.txt.log'}, verify the agent's "
|
|
818
|
+
"tools with 'stargate doctor --probe', then resume: this stage "
|
|
819
|
+
"was not recorded as complete."
|
|
820
|
+
)
|
|
821
|
+
complete_stage(ctx, "developer")
|
|
822
|
+
|
|
823
|
+
test_exit, test_report = run_tests(ctx, "developer")
|
|
824
|
+
|
|
825
|
+
return review_and_finish(
|
|
826
|
+
ctx,
|
|
827
|
+
args,
|
|
828
|
+
prompts,
|
|
829
|
+
task=ctx.task,
|
|
830
|
+
plan=plan,
|
|
831
|
+
test_exit=test_exit,
|
|
832
|
+
test_report=test_report,
|
|
833
|
+
commit=commit,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
VERDICTS = ("APPROVED", "CHANGES_REQUESTED")
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _policy_verdict(
|
|
841
|
+
verdict: str, findings: list[dict[str, Any]], contract: str,
|
|
842
|
+
blocking: tuple[str, ...],
|
|
843
|
+
) -> str:
|
|
844
|
+
# Prose has no findings contract; empty JSON has no evidence for derivation.
|
|
845
|
+
if not blocking or contract != "json" or not findings:
|
|
846
|
+
return verdict
|
|
847
|
+
return "CHANGES_REQUESTED" if any(
|
|
848
|
+
entry["severity"] in blocking for entry in findings
|
|
849
|
+
) else "APPROVED"
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def parse_review(raw: str) -> tuple[str, list[dict[str, Any]], str]:
|
|
853
|
+
"""The reviewer's verdict, findings, and the contract that spoke.
|
|
854
|
+
|
|
855
|
+
A JSON object is the current contract; a response whose last line is
|
|
856
|
+
`VERDICT: ...` is the original one, still spoken by a frozen prompt from a
|
|
857
|
+
run created before this change and by any custom reviewer.md. The verdict
|
|
858
|
+
stands exactly as the reviewer gave it either way -- nothing here derives
|
|
859
|
+
it from the findings.
|
|
860
|
+
"""
|
|
861
|
+
text = raw.strip()
|
|
862
|
+
data: Any = None
|
|
863
|
+
if text.startswith("{"):
|
|
864
|
+
try:
|
|
865
|
+
# raw_decode, not loads: a real reviewer completed the object and
|
|
866
|
+
# then appended a note about what it could not verify, and throwing
|
|
867
|
+
# away a finished, paid review over trailing prose costs a rerun.
|
|
868
|
+
# Still not "find JSON in prose" -- the object must start the
|
|
869
|
+
# response, and anything after it is discarded.
|
|
870
|
+
data, _ = json.JSONDecoder().raw_decode(text)
|
|
871
|
+
except json.JSONDecodeError:
|
|
872
|
+
data = None
|
|
873
|
+
if isinstance(data, dict):
|
|
874
|
+
return (*_parse_json_review(data), "json")
|
|
875
|
+
|
|
876
|
+
# The verdict is the last line, not a substring anywhere in the prose:
|
|
877
|
+
# "I cannot give VERDICT: APPROVED because..." must not read as approval.
|
|
878
|
+
last_line = text.splitlines()[-1].strip() if text else ""
|
|
879
|
+
for verdict in VERDICTS:
|
|
880
|
+
if last_line == f"VERDICT: {verdict}":
|
|
881
|
+
return verdict, [], "prose"
|
|
882
|
+
raise StargateError(
|
|
883
|
+
"Reviewer output is neither a JSON review object nor a response "
|
|
884
|
+
f"ending in exactly 'VERDICT: {VERDICTS[0]}' or "
|
|
885
|
+
f"'VERDICT: {VERDICTS[1]}'."
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _parse_json_review(data: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
|
|
890
|
+
verdict = data.get("verdict")
|
|
891
|
+
if verdict not in VERDICTS:
|
|
892
|
+
raise StargateError(
|
|
893
|
+
"Reviewer JSON needs a 'verdict' of exactly "
|
|
894
|
+
f"{' or '.join(VERDICTS)}; got {verdict!r}."
|
|
895
|
+
)
|
|
896
|
+
raw_findings = data.get("findings", [])
|
|
897
|
+
if not isinstance(raw_findings, list):
|
|
898
|
+
raise StargateError("Reviewer JSON 'findings' must be a list.")
|
|
899
|
+
|
|
900
|
+
findings: list[dict[str, Any]] = []
|
|
901
|
+
for index, item in enumerate(raw_findings):
|
|
902
|
+
where = f"findings[{index}]"
|
|
903
|
+
if not isinstance(item, dict):
|
|
904
|
+
raise StargateError(f"Reviewer JSON {where} must be an object.")
|
|
905
|
+
severity = item.get("severity")
|
|
906
|
+
if not isinstance(severity, str) or severity.lower() not in SEVERITIES:
|
|
907
|
+
raise StargateError(
|
|
908
|
+
f"Reviewer JSON {where}.severity must be one of "
|
|
909
|
+
f"{', '.join(SEVERITIES)}; got {severity!r}."
|
|
910
|
+
)
|
|
911
|
+
finding = item.get("finding")
|
|
912
|
+
if not isinstance(finding, str) or not finding.strip():
|
|
913
|
+
raise StargateError(
|
|
914
|
+
f"Reviewer JSON {where}.finding must be a non-empty string."
|
|
915
|
+
)
|
|
916
|
+
line = item.get("line")
|
|
917
|
+
# bool is a subclass of int: `"line": true` must not pass as a number.
|
|
918
|
+
if line is not None and (
|
|
919
|
+
isinstance(line, bool) or not isinstance(line, int)
|
|
920
|
+
):
|
|
921
|
+
raise StargateError(
|
|
922
|
+
f"Reviewer JSON {where}.line must be an integer when present."
|
|
923
|
+
)
|
|
924
|
+
entry: dict[str, Any] = {
|
|
925
|
+
"severity": severity.lower(),
|
|
926
|
+
"finding": finding.strip(),
|
|
927
|
+
}
|
|
928
|
+
for key in ("file", "why"):
|
|
929
|
+
value = item.get(key)
|
|
930
|
+
if isinstance(value, str) and value.strip():
|
|
931
|
+
entry[key] = value.strip()
|
|
932
|
+
if line is not None:
|
|
933
|
+
entry["line"] = line
|
|
934
|
+
findings.append(entry)
|
|
935
|
+
# Parsing preserves the explicit verdict. Opt-in policy is applied separately.
|
|
936
|
+
return verdict, findings
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _record_review(
|
|
940
|
+
ctx: RunContext, attempt: int, verdict: str, *, fixed: bool = False
|
|
941
|
+
) -> None:
|
|
942
|
+
"""Persist where the review/fix loop got to, so a resume re-enters it.
|
|
943
|
+
|
|
944
|
+
The fingerprint is the tree the reviewer actually judged. A resume trusts
|
|
945
|
+
the recorded verdict only while the worktree still hashes to it.
|
|
946
|
+
"""
|
|
947
|
+
ctx.review = {
|
|
948
|
+
"attempt": attempt,
|
|
949
|
+
"verdict": verdict,
|
|
950
|
+
"fingerprint": worktree_fingerprint(ctx),
|
|
951
|
+
"fixed": fixed,
|
|
952
|
+
}
|
|
953
|
+
if blocking := blocking_severities(ctx.config):
|
|
954
|
+
ctx.review["blocking_severities"] = list(blocking)
|
|
955
|
+
save_state(ctx, "running")
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _recorded_review(
|
|
959
|
+
ctx: RunContext, attempt: int, blocking: tuple[str, ...]
|
|
960
|
+
) -> tuple[str, str] | None:
|
|
961
|
+
"""Re-evaluate a saved pass under the effective (possibly replaced) policy."""
|
|
962
|
+
try:
|
|
963
|
+
review = (ctx.artifacts / f"review-{attempt}.md").read_text().strip()
|
|
964
|
+
except OSError:
|
|
965
|
+
return None
|
|
966
|
+
try:
|
|
967
|
+
verdict, findings, contract = parse_review(review)
|
|
968
|
+
except StargateError:
|
|
969
|
+
# A truncated or hand-edited artifact cannot stand in for a reviewer.
|
|
970
|
+
return None
|
|
971
|
+
return _policy_verdict(verdict, findings, contract, blocking), review
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def review_and_finish(
|
|
975
|
+
ctx: RunContext,
|
|
976
|
+
args: argparse.Namespace,
|
|
977
|
+
prompts: list[Path],
|
|
978
|
+
*,
|
|
979
|
+
task: str,
|
|
980
|
+
plan: str,
|
|
981
|
+
test_exit: int | None,
|
|
982
|
+
test_report: str,
|
|
983
|
+
commit: bool,
|
|
984
|
+
) -> int:
|
|
985
|
+
"""Run the shared review/fix tail for a linear or integrated worktree."""
|
|
986
|
+
|
|
987
|
+
publish = getattr(args, "pr", False)
|
|
988
|
+
# 4. Review/fix loop.
|
|
989
|
+
configured_loops = int(
|
|
990
|
+
ctx.config.get("settings", {}).get("max_review_loops", 2)
|
|
991
|
+
)
|
|
992
|
+
max_loops = args.max_review_loops if args.max_review_loops is not None else configured_loops
|
|
993
|
+
blocking = blocking_severities(ctx.config)
|
|
994
|
+
verdict = "CHANGES_REQUESTED"
|
|
995
|
+
|
|
996
|
+
# A resume must not buy a second opinion on a tree a reviewer already
|
|
997
|
+
# judged: that costs a full review and can return a different finding set
|
|
998
|
+
# than the one whose fixer was interrupted. The recorded cycle stands only
|
|
999
|
+
# while the worktree still hashes to what that reviewer saw.
|
|
1000
|
+
recorded = ctx.review if isinstance(ctx.review, dict) else {}
|
|
1001
|
+
recorded_attempt = int(recorded.get("attempt") or 0)
|
|
1002
|
+
recorded_verdict = str(recorded.get("verdict") or "")
|
|
1003
|
+
unchanged = bool(recorded_attempt) and str(
|
|
1004
|
+
recorded.get("fingerprint") or ""
|
|
1005
|
+
) == worktree_fingerprint(ctx)
|
|
1006
|
+
|
|
1007
|
+
start = 0
|
|
1008
|
+
pending: str | None = None
|
|
1009
|
+
if recorded_attempt and bool(recorded.get("fixed")):
|
|
1010
|
+
# That pass's fixer finished, so the tree moved on and the next review
|
|
1011
|
+
# is owed. Continuing the count keeps max_review_loops a budget for the
|
|
1012
|
+
# run rather than a fresh allowance for every resume of it.
|
|
1013
|
+
start = recorded_attempt
|
|
1014
|
+
elif unchanged and recorded_verdict in VERDICTS:
|
|
1015
|
+
# A disabled policy can change a verdict only if this checkpoint used
|
|
1016
|
+
# one. Without either policy, retain V1's artifact replay rules.
|
|
1017
|
+
policy_applies = bool(blocking or recorded.get("blocking_severities"))
|
|
1018
|
+
replayed = (
|
|
1019
|
+
_recorded_review(ctx, recorded_attempt, blocking)
|
|
1020
|
+
if policy_applies or recorded_verdict == "CHANGES_REQUESTED" else None
|
|
1021
|
+
)
|
|
1022
|
+
if recorded_verdict == "APPROVED" and (
|
|
1023
|
+
not policy_applies or (replayed is not None and replayed[0] == "APPROVED")
|
|
1024
|
+
):
|
|
1025
|
+
# V1 retries the commit without reopening the approved artifact.
|
|
1026
|
+
# If a policy was involved, remember the effective policy for a
|
|
1027
|
+
# later resume, including its removal.
|
|
1028
|
+
if policy_applies:
|
|
1029
|
+
_record_review(ctx, recorded_attempt, "APPROVED")
|
|
1030
|
+
print(f"\n=== REVIEW {recorded_attempt} (skipped, already APPROVED) ===")
|
|
1031
|
+
complete_stage(ctx, "review")
|
|
1032
|
+
return finish(ctx, task, "APPROVED", test_exit, commit=commit, publish=publish)
|
|
1033
|
+
if replayed is not None and (policy_applies or replayed[0] == "CHANGES_REQUESTED"):
|
|
1034
|
+
pending = replayed[1]
|
|
1035
|
+
start = recorded_attempt - 1
|
|
1036
|
+
|
|
1037
|
+
enter_stage(ctx, "review")
|
|
1038
|
+
for attempt in range(start, max_loops + 1):
|
|
1039
|
+
if pending is not None:
|
|
1040
|
+
review, pending = pending, None
|
|
1041
|
+
print(
|
|
1042
|
+
f"\n=== REVIEW {attempt + 1} (skipped, reusing "
|
|
1043
|
+
f"{ctx.artifacts / f'review-{attempt + 1}.md'}) ==="
|
|
1044
|
+
)
|
|
1045
|
+
else:
|
|
1046
|
+
if budget_spent(ctx, f"review {attempt + 1}"):
|
|
1047
|
+
return finish(
|
|
1048
|
+
ctx, task, "BUDGET_EXCEEDED", test_exit, commit=commit, publish=publish
|
|
1049
|
+
)
|
|
1050
|
+
review_prompt = render_prompt(
|
|
1051
|
+
prompts,
|
|
1052
|
+
"reviewer",
|
|
1053
|
+
task=task,
|
|
1054
|
+
base_ref=ctx.base_ref,
|
|
1055
|
+
plan=plan,
|
|
1056
|
+
tests=test_report,
|
|
1057
|
+
)
|
|
1058
|
+
print(f"\n=== REVIEW {attempt + 1} ===")
|
|
1059
|
+
review = invoke_agent(
|
|
1060
|
+
ctx,
|
|
1061
|
+
"reviewer",
|
|
1062
|
+
review_prompt,
|
|
1063
|
+
ctx.worktree,
|
|
1064
|
+
ctx.artifacts / f"review-{attempt + 1}.md",
|
|
1065
|
+
).strip()
|
|
1066
|
+
|
|
1067
|
+
artifact = ctx.artifacts / f"review-{attempt + 1}.md"
|
|
1068
|
+
try:
|
|
1069
|
+
reviewed, ctx.findings, contract = parse_review(review)
|
|
1070
|
+
except StargateError as exc:
|
|
1071
|
+
raise StargateError(f"{exc} See {artifact}") from exc
|
|
1072
|
+
verdict = _policy_verdict(reviewed, ctx.findings, contract, blocking)
|
|
1073
|
+
if verdict != reviewed:
|
|
1074
|
+
print(
|
|
1075
|
+
f"[review {attempt + 1}] settings.blocking_severities "
|
|
1076
|
+
f"({', '.join(blocking)}) derived {verdict}; the reviewer said {reviewed}."
|
|
1077
|
+
)
|
|
1078
|
+
if verdict == "APPROVED":
|
|
1079
|
+
_record_review(ctx, attempt + 1, "APPROVED")
|
|
1080
|
+
break
|
|
1081
|
+
|
|
1082
|
+
_record_review(ctx, attempt + 1, "CHANGES_REQUESTED")
|
|
1083
|
+
if attempt >= max_loops:
|
|
1084
|
+
verdict = "CHANGES_REQUESTED"
|
|
1085
|
+
break
|
|
1086
|
+
|
|
1087
|
+
fixer_prompt = render_prompt(
|
|
1088
|
+
prompts,
|
|
1089
|
+
"fixer",
|
|
1090
|
+
task=task,
|
|
1091
|
+
base_ref=ctx.base_ref,
|
|
1092
|
+
plan=plan,
|
|
1093
|
+
review=review,
|
|
1094
|
+
tests=test_report,
|
|
1095
|
+
)
|
|
1096
|
+
if blocking:
|
|
1097
|
+
fixer_prompt += (
|
|
1098
|
+
"\n\nORCHESTRATOR NOTE (separate from the review above):\n"
|
|
1099
|
+
f"settings.blocking_severities is active: {', '.join(blocking)}. "
|
|
1100
|
+
"JSON reviews with findings block only on these severities; other "
|
|
1101
|
+
"findings are recorded but do not block. Prose and reviews without "
|
|
1102
|
+
"findings keep the reviewer's explicit verdict.\n"
|
|
1103
|
+
)
|
|
1104
|
+
if budget_spent(ctx, f"fixer {attempt + 1}"):
|
|
1105
|
+
return finish(
|
|
1106
|
+
ctx, task, "BUDGET_EXCEEDED", test_exit, commit=commit, publish=publish
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
before = worktree_fingerprint(ctx)
|
|
1110
|
+
print(f"\n=== FIXER {attempt + 1} ===")
|
|
1111
|
+
invoke_agent(
|
|
1112
|
+
ctx,
|
|
1113
|
+
"fixer",
|
|
1114
|
+
fixer_prompt,
|
|
1115
|
+
ctx.worktree,
|
|
1116
|
+
ctx.artifacts / f"fix-{attempt + 1}.txt",
|
|
1117
|
+
)
|
|
1118
|
+
if worktree_fingerprint(ctx) == before:
|
|
1119
|
+
# A fixer may reasonably reject a review finding, but another
|
|
1120
|
+
# review of the identical tree would only repeat the same verdict.
|
|
1121
|
+
print(
|
|
1122
|
+
f"\n[fixer {attempt + 1}] changed nothing; a further review "
|
|
1123
|
+
"would reach the same verdict. Stopping the review loop.",
|
|
1124
|
+
file=sys.stderr,
|
|
1125
|
+
)
|
|
1126
|
+
break
|
|
1127
|
+
_record_review(ctx, attempt + 1, "CHANGES_REQUESTED", fixed=True)
|
|
1128
|
+
test_exit, test_report = run_tests(ctx, f"fix-{attempt + 1}")
|
|
1129
|
+
|
|
1130
|
+
complete_stage(ctx, "review")
|
|
1131
|
+
return finish(ctx, task, verdict, test_exit, commit=commit, publish=publish)
|