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/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)