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/run.py
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
"""The lifecycle of one run: picking its base commit, reserving its id and
|
|
2
|
+
branch, its worktree, the state file that makes it resumable, and cleanup."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import textwrap
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from .config import PROMPTS, find_prompt, token_cap
|
|
22
|
+
from .core import (
|
|
23
|
+
RunContext,
|
|
24
|
+
StargateError,
|
|
25
|
+
git,
|
|
26
|
+
git_quiet,
|
|
27
|
+
short_name,
|
|
28
|
+
slugify,
|
|
29
|
+
)
|
|
30
|
+
from .source import branch_hint
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_base_ref(repo: Path, requested: str | None) -> tuple[str, str]:
|
|
34
|
+
if requested:
|
|
35
|
+
ref = requested
|
|
36
|
+
else:
|
|
37
|
+
proc = git(repo, "rev-parse", "--abbrev-ref", "HEAD", capture=True)
|
|
38
|
+
ref = proc.stdout.strip()
|
|
39
|
+
if ref == "HEAD":
|
|
40
|
+
ref = git(repo, "rev-parse", "HEAD").stdout.strip()
|
|
41
|
+
|
|
42
|
+
commit = git(
|
|
43
|
+
repo, "rev-parse", "--verify", "--end-of-options",
|
|
44
|
+
f"{ref}^{{commit}}", capture=True,
|
|
45
|
+
).stdout.strip()
|
|
46
|
+
|
|
47
|
+
symbolic = subprocess.run(
|
|
48
|
+
["git", "rev-parse", "--symbolic-full-name", "--verify",
|
|
49
|
+
"--end-of-options", ref],
|
|
50
|
+
cwd=str(repo), text=True, capture_output=True,
|
|
51
|
+
)
|
|
52
|
+
local_branch = symbolic.stdout.strip()
|
|
53
|
+
if symbolic.returncode == 0 and local_branch.startswith("refs/heads/"):
|
|
54
|
+
branch = local_branch.removeprefix("refs/heads/")
|
|
55
|
+
remote = subprocess.run(
|
|
56
|
+
["git", "config", "--get", f"branch.{branch}.remote"],
|
|
57
|
+
cwd=str(repo), text=True, capture_output=True,
|
|
58
|
+
).stdout.strip()
|
|
59
|
+
merge_ref = subprocess.run(
|
|
60
|
+
["git", "config", "--get", f"branch.{branch}.merge"],
|
|
61
|
+
cwd=str(repo), text=True, capture_output=True,
|
|
62
|
+
).stdout.strip()
|
|
63
|
+
if remote and merge_ref:
|
|
64
|
+
upstream = (
|
|
65
|
+
merge_ref if remote == "."
|
|
66
|
+
else f"{remote}/{merge_ref.removeprefix('refs/heads/')}"
|
|
67
|
+
)
|
|
68
|
+
if remote == ".":
|
|
69
|
+
upstream_proc = subprocess.run(
|
|
70
|
+
["git", "rev-parse", "--verify", "--end-of-options",
|
|
71
|
+
f"{merge_ref}^{{commit}}"],
|
|
72
|
+
cwd=str(repo), text=True, capture_output=True,
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
try:
|
|
76
|
+
upstream_proc = subprocess.run(
|
|
77
|
+
["git", "ls-remote", "--exit-code", "--refs",
|
|
78
|
+
remote, merge_ref],
|
|
79
|
+
cwd=str(repo), text=True, capture_output=True,
|
|
80
|
+
stdin=subprocess.DEVNULL, timeout=60,
|
|
81
|
+
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
|
82
|
+
)
|
|
83
|
+
except subprocess.TimeoutExpired as exc:
|
|
84
|
+
raise StargateError(
|
|
85
|
+
f"Timed out validating upstream {upstream!r}."
|
|
86
|
+
) from exc
|
|
87
|
+
if upstream_proc.returncode != 0 or not upstream_proc.stdout.strip():
|
|
88
|
+
detail = upstream_proc.stderr.strip() or "ref not found"
|
|
89
|
+
raise StargateError(
|
|
90
|
+
f"Could not validate upstream {upstream!r}: {detail}"
|
|
91
|
+
)
|
|
92
|
+
upstream_commit = upstream_proc.stdout.split()[0]
|
|
93
|
+
if upstream_commit != commit:
|
|
94
|
+
contains_upstream = subprocess.run(
|
|
95
|
+
["git", "merge-base", "--is-ancestor", upstream_commit,
|
|
96
|
+
commit],
|
|
97
|
+
cwd=str(repo), stdout=subprocess.DEVNULL,
|
|
98
|
+
stderr=subprocess.DEVNULL,
|
|
99
|
+
).returncode == 0
|
|
100
|
+
if not contains_upstream:
|
|
101
|
+
raise StargateError(
|
|
102
|
+
f"Base branch {ref!r} is behind or diverged from "
|
|
103
|
+
f"upstream {upstream!r}. Update it before running "
|
|
104
|
+
"Stargate."
|
|
105
|
+
)
|
|
106
|
+
return ref, commit
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def warn_if_dirty(repo: Path) -> None:
|
|
110
|
+
status = git(repo, "status", "--porcelain").stdout.strip()
|
|
111
|
+
if status:
|
|
112
|
+
print(
|
|
113
|
+
"\nWarning: the source repository has local changes. "
|
|
114
|
+
"The agent worktree is created from the selected base ref, "
|
|
115
|
+
"so those uncommitted source-tree changes are NOT copied.",
|
|
116
|
+
file=sys.stderr,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def default_worktree_root(repo: Path) -> Path:
|
|
121
|
+
return repo.parent / ".stargate-worktrees" / repo.name
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def branch_exists(repo: Path, branch: str) -> bool:
|
|
125
|
+
proc = subprocess.run(
|
|
126
|
+
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
|
|
127
|
+
cwd=str(repo), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
128
|
+
)
|
|
129
|
+
return proc.returncode == 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def unique_branch(repo: Path, branch: str) -> str:
|
|
133
|
+
for attempt in range(1, 100):
|
|
134
|
+
candidate = branch if attempt == 1 else f"{branch}-{attempt}"
|
|
135
|
+
if not branch_exists(repo, candidate):
|
|
136
|
+
return candidate
|
|
137
|
+
raise StargateError(f"Could not find an unused branch name based on {branch!r}.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def reserve_run(repo: Path, now: str, slug: str) -> tuple[str, str, Path]:
|
|
141
|
+
"""Reserve one discriminator for both artifacts and the initial branch.
|
|
142
|
+
|
|
143
|
+
A second process in the same second would otherwise share the artifacts
|
|
144
|
+
directory while Git also refuses to attach its branch to another worktree.
|
|
145
|
+
Creating the directory is the atomic claim; old first-attempt names retain
|
|
146
|
+
their exact shape, so `runs` and `resume` need no format migration.
|
|
147
|
+
"""
|
|
148
|
+
for attempt in range(1, 100):
|
|
149
|
+
suffix = "" if attempt == 1 else f"-{attempt}"
|
|
150
|
+
tag = f"{now}{suffix}"
|
|
151
|
+
run_id = f"{now}-{slug}{suffix}"
|
|
152
|
+
branch = f"stargate/{slug}-{tag}"
|
|
153
|
+
if branch_exists(repo, branch):
|
|
154
|
+
continue
|
|
155
|
+
artifacts = repo / ".stargate" / "runs" / run_id
|
|
156
|
+
try:
|
|
157
|
+
artifacts.mkdir(parents=True, exist_ok=False)
|
|
158
|
+
except FileExistsError:
|
|
159
|
+
continue
|
|
160
|
+
return run_id, branch, artifacts
|
|
161
|
+
raise StargateError(
|
|
162
|
+
f"Could not reserve a unique run name for {slug!r} at {now}."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def make_context(
|
|
167
|
+
repo: Path,
|
|
168
|
+
config: dict[str, Any],
|
|
169
|
+
task: str,
|
|
170
|
+
base_ref: str | None,
|
|
171
|
+
name: str | None = None,
|
|
172
|
+
*,
|
|
173
|
+
task_source: str = "",
|
|
174
|
+
) -> RunContext:
|
|
175
|
+
now = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
176
|
+
named = short_name(name or "") if name is not None else ""
|
|
177
|
+
if name is not None and not named:
|
|
178
|
+
print(
|
|
179
|
+
"Warning: --name produced no usable slug; using the task text.",
|
|
180
|
+
file=sys.stderr,
|
|
181
|
+
)
|
|
182
|
+
slug = named or slugify(f"{branch_hint(task_source)} {task}")
|
|
183
|
+
base, base_commit = resolve_base_ref(repo, base_ref)
|
|
184
|
+
run_id, branch, artifacts = reserve_run(repo, now, slug)
|
|
185
|
+
tag = branch.removeprefix(f"stargate/{slug}-")
|
|
186
|
+
|
|
187
|
+
configured = str(config.get("settings", {}).get("worktree_root", "") or "").strip()
|
|
188
|
+
worktree_root = (
|
|
189
|
+
Path(os.path.expanduser(configured)).resolve()
|
|
190
|
+
if configured else default_worktree_root(repo)
|
|
191
|
+
)
|
|
192
|
+
worktree = worktree_root / run_id
|
|
193
|
+
# Keep run artifacts out of the target repo's git status without touching
|
|
194
|
+
# the user's own .gitignore.
|
|
195
|
+
(repo / ".stargate" / ".gitignore").write_text("*\n")
|
|
196
|
+
worktree.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
|
|
198
|
+
return RunContext(
|
|
199
|
+
repo=repo,
|
|
200
|
+
config=config,
|
|
201
|
+
run_id=run_id,
|
|
202
|
+
slug=slug,
|
|
203
|
+
branch=branch,
|
|
204
|
+
base_ref=base,
|
|
205
|
+
base_commit=base_commit,
|
|
206
|
+
worktree=worktree,
|
|
207
|
+
artifacts=artifacts,
|
|
208
|
+
task=task,
|
|
209
|
+
task_source=task_source,
|
|
210
|
+
tag=tag,
|
|
211
|
+
named_by_user=bool(named),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def budget_spent(ctx: RunContext, next_phase: str) -> bool:
|
|
216
|
+
"""Whether the cap is reached. Checked BETWEEN phases: nothing here can stop
|
|
217
|
+
an agent already running, so a single runaway invocation still overshoots."""
|
|
218
|
+
cap = token_cap(ctx.config)
|
|
219
|
+
if not cap or ctx.tokens_used < cap:
|
|
220
|
+
return False
|
|
221
|
+
print(
|
|
222
|
+
f"\nToken budget reached: {ctx.tokens_used:,} of {cap:,} used. "
|
|
223
|
+
f"Stopping before {next_phase}.",
|
|
224
|
+
file=sys.stderr,
|
|
225
|
+
)
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
STAGES = ("architect", "worktree", "developer", "review")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# These are the completed records that skip work. The worktree is reused
|
|
233
|
+
# regardless, and the review loop already restarts from its first attempt.
|
|
234
|
+
REDOABLE_STAGES = ("architect", "developer")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def save_state(ctx: RunContext, status: str, error: str | None = None) -> None:
|
|
238
|
+
"""Record where the run got to, so a failed stage can be resumed instead of
|
|
239
|
+
restarting the whole flow and leaving a second plan, branch and worktree."""
|
|
240
|
+
path = ctx.artifacts / "state.json"
|
|
241
|
+
started = json.loads(path.read_text()).get("started_at") if path.exists() else None
|
|
242
|
+
contents = json.dumps({
|
|
243
|
+
"run_id": ctx.run_id,
|
|
244
|
+
"task": ctx.task,
|
|
245
|
+
"task_source": ctx.task_source or None,
|
|
246
|
+
"repo": str(ctx.repo),
|
|
247
|
+
"base_ref": ctx.base_ref,
|
|
248
|
+
"base_commit": ctx.base_commit,
|
|
249
|
+
"branch": ctx.branch,
|
|
250
|
+
"worktree": str(ctx.worktree),
|
|
251
|
+
"stage": ctx.stage,
|
|
252
|
+
"status": status,
|
|
253
|
+
"error": error,
|
|
254
|
+
"completed": sorted(ctx.done),
|
|
255
|
+
"tokens_used": ctx.tokens_used,
|
|
256
|
+
"named_by_user": ctx.named_by_user,
|
|
257
|
+
"test_artifacts": sorted(ctx.test_artifacts),
|
|
258
|
+
"commit": ctx.commit or None,
|
|
259
|
+
"commit_error": ctx.commit_error or None,
|
|
260
|
+
"mode": ctx.mode,
|
|
261
|
+
"fanout": ctx.fanout or None,
|
|
262
|
+
"review": ctx.review or None,
|
|
263
|
+
"findings": ctx.findings or None,
|
|
264
|
+
"started_at": started or dt.datetime.now().isoformat(timespec="seconds"),
|
|
265
|
+
"updated_at": dt.datetime.now().isoformat(timespec="seconds"),
|
|
266
|
+
}, indent=2) + "\n"
|
|
267
|
+
temporary: Path | None = None
|
|
268
|
+
try:
|
|
269
|
+
with tempfile.NamedTemporaryFile(
|
|
270
|
+
"w",
|
|
271
|
+
encoding="utf-8",
|
|
272
|
+
dir=path.parent,
|
|
273
|
+
prefix=f".{path.name}.",
|
|
274
|
+
suffix=".tmp",
|
|
275
|
+
delete=False,
|
|
276
|
+
) as handle:
|
|
277
|
+
temporary = Path(handle.name)
|
|
278
|
+
handle.write(contents)
|
|
279
|
+
handle.flush()
|
|
280
|
+
os.fsync(handle.fileno())
|
|
281
|
+
os.replace(temporary, path)
|
|
282
|
+
finally:
|
|
283
|
+
if temporary is not None:
|
|
284
|
+
with contextlib.suppress(FileNotFoundError):
|
|
285
|
+
temporary.unlink()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def enter_stage(ctx: RunContext, stage: str) -> None:
|
|
289
|
+
ctx.stage = stage
|
|
290
|
+
save_state(ctx, "running")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def complete_stage(ctx: RunContext, stage: str) -> None:
|
|
294
|
+
ctx.done.add(stage)
|
|
295
|
+
save_state(ctx, "running")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def load_run(repo: Path, run_id: str, config: dict[str, Any], use_frozen: bool) -> RunContext:
|
|
299
|
+
artifacts = repo / ".stargate" / "runs" / run_id
|
|
300
|
+
state_path = artifacts / "state.json"
|
|
301
|
+
if not state_path.exists():
|
|
302
|
+
raise StargateError(f"No run state at {state_path}")
|
|
303
|
+
state = json.loads(state_path.read_text())
|
|
304
|
+
if not isinstance(state, dict):
|
|
305
|
+
raise StargateError(f"Cannot load {run_id}: state.json is not a JSON object.")
|
|
306
|
+
# Runs created before mode was persisted predate fan-out and are linear.
|
|
307
|
+
# An explicitly stored invalid value is still corruption, not a migration.
|
|
308
|
+
mode = state["mode"] if "mode" in state else "linear"
|
|
309
|
+
if mode not in ("linear", "fanout"):
|
|
310
|
+
raise StargateError(
|
|
311
|
+
f"Cannot load {run_id}: state.json has no valid mode "
|
|
312
|
+
"(expected 'linear' or 'fanout')."
|
|
313
|
+
)
|
|
314
|
+
frozen = artifacts / "config.yaml"
|
|
315
|
+
if use_frozen and frozen.exists():
|
|
316
|
+
# Default to the run's own frozen config so resuming does not silently
|
|
317
|
+
# change the agents the earlier stages ran under. An explicit --config
|
|
318
|
+
# overrides it, which is how you resume past a bad agent definition.
|
|
319
|
+
config = yaml.safe_load(frozen.read_text()) or config
|
|
320
|
+
return RunContext(
|
|
321
|
+
repo=repo,
|
|
322
|
+
config=config,
|
|
323
|
+
run_id=state["run_id"],
|
|
324
|
+
slug=slugify(state["task"]),
|
|
325
|
+
branch=state["branch"],
|
|
326
|
+
base_ref=state["base_ref"],
|
|
327
|
+
base_commit=str(state.get("base_commit") or state["base_ref"]),
|
|
328
|
+
worktree=Path(state["worktree"]),
|
|
329
|
+
artifacts=artifacts,
|
|
330
|
+
task=state["task"],
|
|
331
|
+
task_source=str(state.get("task_source") or ""),
|
|
332
|
+
stage=state.get("stage", "init"),
|
|
333
|
+
done=set(state.get("completed", [])),
|
|
334
|
+
tokens_used=int(state.get("tokens_used", 0)),
|
|
335
|
+
test_artifacts=set(state.get("test_artifacts", [])),
|
|
336
|
+
commit=str(state.get("commit") or ""),
|
|
337
|
+
commit_error=str(state.get("commit_error") or ""),
|
|
338
|
+
mode=mode,
|
|
339
|
+
fanout=dict(state.get("fanout") or {}),
|
|
340
|
+
review=dict(state.get("review") or {}),
|
|
341
|
+
findings=list(state.get("findings") or []),
|
|
342
|
+
tag=(
|
|
343
|
+
match.group(1)
|
|
344
|
+
if (match := re.search(
|
|
345
|
+
r"-(\d{8}-\d{6}(?:-\d+)?)$", str(state["branch"])
|
|
346
|
+
)) else ""
|
|
347
|
+
),
|
|
348
|
+
named_by_user=bool(state.get("named_by_user", False)),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
RESUMABLE_STATUSES = ("running", "failed", "budget_exceeded")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def read_run(path: Path) -> dict[str, Any]:
|
|
356
|
+
"""Build one listing row, including runs whose state cannot be read."""
|
|
357
|
+
state_path = path / "state.json"
|
|
358
|
+
row: dict[str, Any] = {
|
|
359
|
+
"run_id": path.name,
|
|
360
|
+
"status": "unknown",
|
|
361
|
+
"stage": "-",
|
|
362
|
+
"branch": "(unknown)",
|
|
363
|
+
"worktree": "(unknown)",
|
|
364
|
+
"worktree_missing": False,
|
|
365
|
+
"task": "-",
|
|
366
|
+
"updated": "-",
|
|
367
|
+
"resumable": False,
|
|
368
|
+
"error": "",
|
|
369
|
+
}
|
|
370
|
+
try:
|
|
371
|
+
state = json.loads(state_path.read_text())
|
|
372
|
+
if not isinstance(state, dict):
|
|
373
|
+
raise ValueError("state.json is not an object")
|
|
374
|
+
except (OSError, ValueError) as exc:
|
|
375
|
+
row["error"] = f"unreadable state.json ({exc})"
|
|
376
|
+
return row
|
|
377
|
+
|
|
378
|
+
status = " ".join(str(state.get("status") or "unknown").split())
|
|
379
|
+
worktree = str(state.get("worktree") or "")
|
|
380
|
+
missing = False
|
|
381
|
+
if worktree:
|
|
382
|
+
try:
|
|
383
|
+
missing = not Path(worktree).exists()
|
|
384
|
+
except (OSError, ValueError):
|
|
385
|
+
missing = True
|
|
386
|
+
row.update(
|
|
387
|
+
run_id=" ".join(str(state.get("run_id") or path.name).split()),
|
|
388
|
+
status=status,
|
|
389
|
+
stage=" ".join(str(state.get("stage") or "-").split()),
|
|
390
|
+
branch=" ".join(str(state.get("branch") or "(unknown)").split()),
|
|
391
|
+
worktree=" ".join(worktree.split()) or "(unknown)",
|
|
392
|
+
worktree_missing=missing,
|
|
393
|
+
updated=" ".join(str(state.get("updated_at") or "-").split()),
|
|
394
|
+
task=(" ".join(str(state.get("task") or "").split()) or "-"),
|
|
395
|
+
# A run that reached a verdict but could not commit -- a signing
|
|
396
|
+
# prompt that timed out, a hook that rejected the tree -- is exactly
|
|
397
|
+
# the run a resume can finish, whatever terminal status it recorded.
|
|
398
|
+
resumable=(
|
|
399
|
+
status.lower() in RESUMABLE_STATUSES
|
|
400
|
+
or bool(state.get("commit_error"))
|
|
401
|
+
),
|
|
402
|
+
error="",
|
|
403
|
+
)
|
|
404
|
+
return row
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def inherited_findings(repo: Path, base_ref: str) -> tuple[str, list[Any]]:
|
|
408
|
+
"""Find the base branch's recorded run and its last completed findings.
|
|
409
|
+
|
|
410
|
+
Match the stored branch exactly: run ids and branches use different name
|
|
411
|
+
orders, and an architect rename or collision makes inversion unreliable.
|
|
412
|
+
Missing or unreadable bookkeeping must never prevent a new run, and neither
|
|
413
|
+
does a run whose branch never received its commit.
|
|
414
|
+
"""
|
|
415
|
+
if not base_ref.startswith("stargate/"):
|
|
416
|
+
return "", []
|
|
417
|
+
root = repo / ".stargate" / "runs"
|
|
418
|
+
try:
|
|
419
|
+
paths = sorted(
|
|
420
|
+
(path for path in root.iterdir() if path.is_dir()),
|
|
421
|
+
key=lambda path: path.name,
|
|
422
|
+
reverse=True,
|
|
423
|
+
)
|
|
424
|
+
except OSError:
|
|
425
|
+
return "", []
|
|
426
|
+
for path in paths:
|
|
427
|
+
try:
|
|
428
|
+
state = json.loads((path / "state.json").read_text())
|
|
429
|
+
except (OSError, ValueError):
|
|
430
|
+
continue
|
|
431
|
+
if not isinstance(state, dict) or state.get("branch") != base_ref:
|
|
432
|
+
continue
|
|
433
|
+
if not state.get("commit"):
|
|
434
|
+
# The run left no terminal commit -- settings.commit was false, or
|
|
435
|
+
# the commit failed -- so its branch does not carry the work those
|
|
436
|
+
# findings describe. Inheriting them would hand the architect a
|
|
437
|
+
# review of a tree this run cannot see.
|
|
438
|
+
return "", []
|
|
439
|
+
findings = state.get("findings")
|
|
440
|
+
if not isinstance(findings, list) or not findings:
|
|
441
|
+
return "", []
|
|
442
|
+
return str(state.get("run_id") or path.name), findings
|
|
443
|
+
return "", []
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def list_runs(repo: Path) -> int:
|
|
447
|
+
root = repo / ".stargate" / "runs"
|
|
448
|
+
# Listing a repository that has never run stargate must not create the
|
|
449
|
+
# bookkeeping directory it is meant only to inspect.
|
|
450
|
+
if not root.is_dir():
|
|
451
|
+
print(f"No recorded runs in {root}")
|
|
452
|
+
return 0
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
paths = sorted(
|
|
456
|
+
(path for path in root.iterdir() if path.is_dir()),
|
|
457
|
+
key=lambda path: path.name,
|
|
458
|
+
reverse=True,
|
|
459
|
+
)
|
|
460
|
+
except OSError as exc:
|
|
461
|
+
raise StargateError(
|
|
462
|
+
f"Could not read recorded runs in {root}: {exc}"
|
|
463
|
+
) from exc
|
|
464
|
+
if not paths:
|
|
465
|
+
print(f"No recorded runs in {root}")
|
|
466
|
+
return 0
|
|
467
|
+
|
|
468
|
+
rows = [read_run(path) for path in paths]
|
|
469
|
+
width = max(20, shutil.get_terminal_size().columns)
|
|
470
|
+
print(f"Runs in {repo} (newest first):\n")
|
|
471
|
+
for index, row in enumerate(rows):
|
|
472
|
+
if index:
|
|
473
|
+
print()
|
|
474
|
+
marker = "*" if row["resumable"] else " "
|
|
475
|
+
print(f"{marker} {row['run_id']} [{row['status']}]")
|
|
476
|
+
updated = row["updated"].replace("T", " ", 1)
|
|
477
|
+
print(f" updated {updated} | stage {row['stage']}")
|
|
478
|
+
print(
|
|
479
|
+
textwrap.fill(
|
|
480
|
+
row["task"], width=width,
|
|
481
|
+
initial_indent=" task ", subsequent_indent=" ",
|
|
482
|
+
max_lines=3, placeholder=" ...",
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
print(f" branch {row['branch']}")
|
|
486
|
+
missing = " (MISSING)" if row["worktree_missing"] else ""
|
|
487
|
+
print(f" worktree {row['worktree']}{missing}")
|
|
488
|
+
if row["error"]:
|
|
489
|
+
print(f" {row['error']}")
|
|
490
|
+
|
|
491
|
+
newest = next((row for row in rows if row["resumable"]), None)
|
|
492
|
+
if newest:
|
|
493
|
+
print(
|
|
494
|
+
"\n* resumable. Resume the newest with: "
|
|
495
|
+
f"stargate resume {newest['run_id']}"
|
|
496
|
+
)
|
|
497
|
+
else:
|
|
498
|
+
print("\nNo runs are marked resumable.")
|
|
499
|
+
return 0
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def snapshot(ctx: RunContext, dirs: list[Path]) -> list[Path]:
|
|
503
|
+
"""Copy the effective config and all four prompts into the run's artifacts,
|
|
504
|
+
and use those copies for the rest of the run.
|
|
505
|
+
|
|
506
|
+
A run outlives its own installation: upgrading or reinstalling the package
|
|
507
|
+
mid-run otherwise deletes the prompts out from under the next role. It also
|
|
508
|
+
makes the run reproducible -- the artifacts say exactly what was used.
|
|
509
|
+
"""
|
|
510
|
+
(ctx.artifacts / "config.yaml").write_text(yaml.safe_dump(ctx.config, sort_keys=False))
|
|
511
|
+
frozen = ctx.artifacts / "prompts"
|
|
512
|
+
frozen.mkdir(exist_ok=True)
|
|
513
|
+
for name in PROMPTS:
|
|
514
|
+
(frozen / f"{name}.md").write_text(find_prompt(dirs, name).read_text())
|
|
515
|
+
return [frozen]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _registered_worktrees(
|
|
519
|
+
repo: Path,
|
|
520
|
+
) -> tuple[dict[Path, str | None], set[Path]]:
|
|
521
|
+
"""Return registered worktree branches and paths locked against removal."""
|
|
522
|
+
raw = git_quiet(repo, "worktree", "list", "--porcelain", "-z")
|
|
523
|
+
registrations: dict[Path, str | None] = {}
|
|
524
|
+
locked: set[Path] = set()
|
|
525
|
+
for record in raw.split("\0\0"):
|
|
526
|
+
fields = [field for field in record.split("\0") if field]
|
|
527
|
+
path_value = next(
|
|
528
|
+
(field.removeprefix("worktree ") for field in fields
|
|
529
|
+
if field.startswith("worktree ")),
|
|
530
|
+
None,
|
|
531
|
+
)
|
|
532
|
+
if path_value is None:
|
|
533
|
+
continue
|
|
534
|
+
branch = next(
|
|
535
|
+
(field.removeprefix("branch refs/heads/") for field in fields
|
|
536
|
+
if field.startswith("branch refs/heads/")),
|
|
537
|
+
None,
|
|
538
|
+
)
|
|
539
|
+
path = Path(path_value).resolve()
|
|
540
|
+
registrations[path] = branch
|
|
541
|
+
if any(field == "locked" or field.startswith("locked ") for field in fields):
|
|
542
|
+
locked.add(path)
|
|
543
|
+
return registrations, locked
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def create_worktree(
|
|
547
|
+
ctx: RunContext,
|
|
548
|
+
*,
|
|
549
|
+
recover_recorded_fanout_branch: bool = False,
|
|
550
|
+
) -> None:
|
|
551
|
+
worktree = ctx.worktree.resolve()
|
|
552
|
+
registrations, _ = _registered_worktrees(ctx.repo)
|
|
553
|
+
registered = worktree in registrations
|
|
554
|
+
registered_branch = registrations.get(worktree)
|
|
555
|
+
if ctx.worktree.exists():
|
|
556
|
+
if not registered:
|
|
557
|
+
raise StargateError(
|
|
558
|
+
f"Worktree path {ctx.worktree} exists but is not a registered "
|
|
559
|
+
f"Git worktree for {ctx.repo}; refusing to reuse it."
|
|
560
|
+
)
|
|
561
|
+
if registered_branch != ctx.branch:
|
|
562
|
+
raise StargateError(
|
|
563
|
+
f"Worktree path {ctx.worktree} is registered on branch "
|
|
564
|
+
f"{registered_branch!r}, not expected branch {ctx.branch!r}; "
|
|
565
|
+
"refusing to reuse it."
|
|
566
|
+
)
|
|
567
|
+
print(f"Reusing existing worktree: {ctx.worktree}")
|
|
568
|
+
return
|
|
569
|
+
if registered:
|
|
570
|
+
raise StargateError(
|
|
571
|
+
f"Worktree path {ctx.worktree} is registered on branch "
|
|
572
|
+
f"{registered_branch!r}, but the path is missing; refusing to recreate it."
|
|
573
|
+
)
|
|
574
|
+
branch_worktree = next(
|
|
575
|
+
(path for path, branch in registrations.items() if branch == ctx.branch),
|
|
576
|
+
None,
|
|
577
|
+
)
|
|
578
|
+
if branch_worktree is not None:
|
|
579
|
+
raise StargateError(
|
|
580
|
+
f"Branch {ctx.branch!r} is already registered at {branch_worktree}, "
|
|
581
|
+
f"not the expected worktree {ctx.worktree}."
|
|
582
|
+
)
|
|
583
|
+
if ctx.mode.startswith("fanout"):
|
|
584
|
+
exists = branch_exists(ctx.repo, ctx.branch)
|
|
585
|
+
if exists:
|
|
586
|
+
state_path = ctx.artifacts / "state.json"
|
|
587
|
+
try:
|
|
588
|
+
recorded = json.loads(state_path.read_text())
|
|
589
|
+
except (OSError, ValueError):
|
|
590
|
+
recorded = None
|
|
591
|
+
recorded_worktree = (
|
|
592
|
+
recorded.get("worktree") if isinstance(recorded, dict) else None
|
|
593
|
+
)
|
|
594
|
+
# The run's own state.json is the ownership record: a branch this
|
|
595
|
+
# run registered on this path stays adoptable after `git worktree
|
|
596
|
+
# prune` reclaims the directory, for the integration worktree as
|
|
597
|
+
# much as for a task's.
|
|
598
|
+
saved_run_matches = isinstance(recorded, dict) and (
|
|
599
|
+
recorded.get("mode") == ctx.mode
|
|
600
|
+
and recorded.get("run_id") == ctx.run_id
|
|
601
|
+
and recorded.get("branch") == ctx.branch
|
|
602
|
+
and isinstance(recorded_worktree, str)
|
|
603
|
+
and Path(recorded_worktree).resolve() == worktree
|
|
604
|
+
)
|
|
605
|
+
branch_is_recorded = saved_run_matches or (
|
|
606
|
+
ctx.mode == "fanout-task" and recover_recorded_fanout_branch
|
|
607
|
+
)
|
|
608
|
+
if not branch_is_recorded:
|
|
609
|
+
raise StargateError(
|
|
610
|
+
f"Branch {ctx.branch!r} already exists without the expected registered "
|
|
611
|
+
"worktree; refusing to adopt it."
|
|
612
|
+
)
|
|
613
|
+
else:
|
|
614
|
+
exists = (
|
|
615
|
+
git(ctx.repo, "rev-parse", "--verify", ctx.branch, check=False).returncode == 0
|
|
616
|
+
)
|
|
617
|
+
args = ["worktree", "add"] + (
|
|
618
|
+
[str(ctx.worktree), ctx.branch]
|
|
619
|
+
if exists
|
|
620
|
+
else ["-b", ctx.branch, str(ctx.worktree), ctx.base_commit]
|
|
621
|
+
)
|
|
622
|
+
git(ctx.repo, *args, capture=True)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def clean_run(repo: Path, run_id: str, *, validate_only: bool = False) -> None:
|
|
626
|
+
root = repo / ".stargate" / "runs"
|
|
627
|
+
if not run_id or Path(run_id).name != run_id or run_id in (".", ".."):
|
|
628
|
+
raise StargateError(f"Invalid run ID: {run_id!r}")
|
|
629
|
+
artifacts = root / run_id
|
|
630
|
+
if artifacts.is_symlink() or not artifacts.is_dir():
|
|
631
|
+
raise StargateError(f"No recorded run {run_id!r} in {root}")
|
|
632
|
+
try:
|
|
633
|
+
state = json.loads((artifacts / "state.json").read_text())
|
|
634
|
+
except (OSError, ValueError) as exc:
|
|
635
|
+
raise StargateError(f"Cannot clean {run_id}: unreadable state.json ({exc})") from exc
|
|
636
|
+
if not isinstance(state, dict) or state.get("run_id") != run_id:
|
|
637
|
+
raise StargateError(f"Cannot clean {run_id}: state.json has a different run ID")
|
|
638
|
+
recorded_repo = state.get("repo")
|
|
639
|
+
if not isinstance(recorded_repo, str) or Path(recorded_repo).resolve() != repo:
|
|
640
|
+
raise StargateError(f"Cannot clean {run_id}: state.json belongs to another repository")
|
|
641
|
+
branch = state.get("branch")
|
|
642
|
+
worktree_value = state.get("worktree")
|
|
643
|
+
if not isinstance(branch, str) or not branch.startswith("stargate/"):
|
|
644
|
+
raise StargateError(f"Cannot clean {run_id}: invalid Stargate branch")
|
|
645
|
+
if not isinstance(worktree_value, str) or not worktree_value:
|
|
646
|
+
raise StargateError(f"Cannot clean {run_id}: invalid worktree path")
|
|
647
|
+
worktree = Path(worktree_value).resolve()
|
|
648
|
+
targets = [(branch, worktree)]
|
|
649
|
+
fanout = state.get("fanout") if state.get("mode") == "fanout" else None
|
|
650
|
+
if fanout:
|
|
651
|
+
records = fanout.get("tasks") if isinstance(fanout, dict) else None
|
|
652
|
+
if not isinstance(records, dict):
|
|
653
|
+
raise StargateError(f"Cannot clean {run_id}: invalid fan-out task state")
|
|
654
|
+
for task_id, record in records.items():
|
|
655
|
+
task_branch = record.get("branch") if isinstance(record, dict) else None
|
|
656
|
+
task_worktree = record.get("worktree") if isinstance(record, dict) else None
|
|
657
|
+
expected_branch = f"{branch}-{task_id}"
|
|
658
|
+
if (
|
|
659
|
+
not isinstance(task_id, str)
|
|
660
|
+
or not isinstance(task_branch, str)
|
|
661
|
+
or not isinstance(task_worktree, str)
|
|
662
|
+
or not task_worktree
|
|
663
|
+
):
|
|
664
|
+
raise StargateError(
|
|
665
|
+
f"Cannot clean {run_id}: invalid fan-out task target {task_id!r}"
|
|
666
|
+
)
|
|
667
|
+
task_path = Path(task_worktree).resolve()
|
|
668
|
+
branch_matches = task_branch == expected_branch or re.fullmatch(
|
|
669
|
+
rf"{re.escape(expected_branch)}-(?:[2-9]|[1-9][0-9]+)",
|
|
670
|
+
task_branch,
|
|
671
|
+
)
|
|
672
|
+
expected_path = (worktree.parent / f"{run_id}-{task_id}").resolve()
|
|
673
|
+
if not branch_matches or task_path != expected_path:
|
|
674
|
+
raise StargateError(
|
|
675
|
+
f"Cannot clean {run_id}: fan-out task {task_id!r} does not match "
|
|
676
|
+
"its recorded branch/worktree naming"
|
|
677
|
+
)
|
|
678
|
+
targets.append((task_branch, task_path))
|
|
679
|
+
|
|
680
|
+
registrations, locked = _registered_worktrees(repo)
|
|
681
|
+
present = [
|
|
682
|
+
(target_branch, target_worktree, branch_exists(repo, target_branch),
|
|
683
|
+
target_worktree.exists(), target_worktree in registrations)
|
|
684
|
+
for target_branch, target_worktree in targets
|
|
685
|
+
]
|
|
686
|
+
if not any(branch_present or worktree_present or registered
|
|
687
|
+
for _, _, branch_present, worktree_present, registered in present):
|
|
688
|
+
if validate_only:
|
|
689
|
+
return
|
|
690
|
+
shutil.rmtree(artifacts)
|
|
691
|
+
print(
|
|
692
|
+
f"Cleaned {run_id}: artifacts removed; worktrees and branches were absent."
|
|
693
|
+
)
|
|
694
|
+
return
|
|
695
|
+
|
|
696
|
+
# Validate every target before removing the first one. A dirty sibling must
|
|
697
|
+
# not turn a fan-out cleanup into a partially destructive operation.
|
|
698
|
+
for (
|
|
699
|
+
target_branch,
|
|
700
|
+
target_worktree,
|
|
701
|
+
branch_present,
|
|
702
|
+
worktree_present,
|
|
703
|
+
registered,
|
|
704
|
+
) in present:
|
|
705
|
+
registered_branch = registrations.get(target_worktree)
|
|
706
|
+
if worktree_present and not registered:
|
|
707
|
+
raise StargateError(
|
|
708
|
+
f"Cannot clean {run_id}: {target_worktree} is not a registered "
|
|
709
|
+
"Git worktree"
|
|
710
|
+
)
|
|
711
|
+
if registered and registered_branch != target_branch:
|
|
712
|
+
raise StargateError(
|
|
713
|
+
f"Cannot clean {run_id}: {target_worktree} is registered on branch "
|
|
714
|
+
f"{registered_branch!r}, not {target_branch!r}"
|
|
715
|
+
)
|
|
716
|
+
if registered and target_worktree in locked:
|
|
717
|
+
raise StargateError(
|
|
718
|
+
f"Cannot clean {run_id}: worktree {target_worktree} is locked"
|
|
719
|
+
)
|
|
720
|
+
other_path = next(
|
|
721
|
+
(
|
|
722
|
+
path for path, checked_out in registrations.items()
|
|
723
|
+
if checked_out == target_branch and path != target_worktree
|
|
724
|
+
),
|
|
725
|
+
None,
|
|
726
|
+
)
|
|
727
|
+
if other_path is not None:
|
|
728
|
+
raise StargateError(
|
|
729
|
+
f"Cannot clean {run_id}: branch {target_branch!r} is registered "
|
|
730
|
+
f"at {other_path}, not {target_worktree}"
|
|
731
|
+
)
|
|
732
|
+
if worktree_present:
|
|
733
|
+
if git_quiet(target_worktree, "status", "--porcelain"):
|
|
734
|
+
raise StargateError(
|
|
735
|
+
f"Cannot clean {run_id}: worktree {target_worktree} is dirty"
|
|
736
|
+
)
|
|
737
|
+
if not branch_present:
|
|
738
|
+
continue
|
|
739
|
+
merged = subprocess.run(
|
|
740
|
+
[
|
|
741
|
+
"git", "merge-base", "--is-ancestor",
|
|
742
|
+
f"refs/heads/{target_branch}", "HEAD",
|
|
743
|
+
],
|
|
744
|
+
cwd=str(repo), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
745
|
+
)
|
|
746
|
+
if merged.returncode == 1:
|
|
747
|
+
raise StargateError(
|
|
748
|
+
f"Cannot clean {run_id}: branch {target_branch!r} is not merged into HEAD"
|
|
749
|
+
)
|
|
750
|
+
if merged.returncode != 0:
|
|
751
|
+
raise StargateError(
|
|
752
|
+
f"Cannot determine whether branch {target_branch!r} is merged"
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
if validate_only:
|
|
756
|
+
return
|
|
757
|
+
for _, target_worktree, _, worktree_present, registered in reversed(present):
|
|
758
|
+
if worktree_present:
|
|
759
|
+
git(repo, "worktree", "remove", str(target_worktree))
|
|
760
|
+
elif registered:
|
|
761
|
+
# Remove only this stale registration. A global prune could also
|
|
762
|
+
# discard an unrelated user's temporarily missing worktree.
|
|
763
|
+
git(repo, "worktree", "remove", "--force", str(target_worktree))
|
|
764
|
+
for target_branch, _, branch_present, _, _ in reversed(present):
|
|
765
|
+
if branch_present:
|
|
766
|
+
git(repo, "branch", "-d", "--", target_branch)
|
|
767
|
+
shutil.rmtree(artifacts)
|
|
768
|
+
print(f"Cleaned {run_id}: worktrees, branches and artifacts removed.")
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def clean_runs(repo: Path, run_id: str | None, all_runs: bool) -> int:
|
|
772
|
+
if all_runs == (run_id is not None):
|
|
773
|
+
raise StargateError("Use either 'stargate clean <run-id>' or 'stargate clean --all'.")
|
|
774
|
+
if run_id is not None:
|
|
775
|
+
clean_run(repo, run_id)
|
|
776
|
+
return 0
|
|
777
|
+
|
|
778
|
+
root = repo / ".stargate" / "runs"
|
|
779
|
+
if not root.is_dir():
|
|
780
|
+
print(f"No recorded runs in {root}")
|
|
781
|
+
return 0
|
|
782
|
+
run_ids = sorted(
|
|
783
|
+
(path.name for path in root.iterdir() if path.is_dir()), reverse=True
|
|
784
|
+
)
|
|
785
|
+
if not run_ids:
|
|
786
|
+
print(f"No recorded runs in {root}")
|
|
787
|
+
return 0
|
|
788
|
+
# Atomicity is per run: a run is never partially destroyed, but one run
|
|
789
|
+
# that fails its checks -- an in-progress run is the common case -- must
|
|
790
|
+
# not hold every unrelated finished run hostage.
|
|
791
|
+
failures: list[tuple[str, str]] = []
|
|
792
|
+
cleanable: list[str] = []
|
|
793
|
+
for candidate in run_ids:
|
|
794
|
+
try:
|
|
795
|
+
clean_run(repo, candidate, validate_only=True)
|
|
796
|
+
except StargateError as exc:
|
|
797
|
+
failures.append((candidate, str(exc)))
|
|
798
|
+
else:
|
|
799
|
+
cleanable.append(candidate)
|
|
800
|
+
for candidate in cleanable:
|
|
801
|
+
try:
|
|
802
|
+
clean_run(repo, candidate)
|
|
803
|
+
except StargateError as exc:
|
|
804
|
+
# Re-checked at removal time, so state that changed since the
|
|
805
|
+
# preflight is reported like any other skipped run.
|
|
806
|
+
failures.append((candidate, str(exc)))
|
|
807
|
+
if failures:
|
|
808
|
+
for candidate, error in sorted(failures):
|
|
809
|
+
print(f" {candidate}: {error}", file=sys.stderr)
|
|
810
|
+
print(
|
|
811
|
+
f"Skipped {len(failures)} of {len(run_ids)} runs that failed "
|
|
812
|
+
"safety checks; the remaining runs were cleaned.",
|
|
813
|
+
file=sys.stderr,
|
|
814
|
+
)
|
|
815
|
+
return 1
|
|
816
|
+
return 0
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def worktree_fingerprint(ctx: RunContext) -> str:
|
|
820
|
+
"""Digest the tracked and untracked state that an agent can change."""
|
|
821
|
+
parts = [git_quiet(ctx.worktree, "diff", ctx.base_commit)]
|
|
822
|
+
untracked = git_quiet(
|
|
823
|
+
ctx.worktree, "ls-files", "--others", "--exclude-standard", "-z"
|
|
824
|
+
)
|
|
825
|
+
for name in untracked.split("\0"):
|
|
826
|
+
if not name:
|
|
827
|
+
continue
|
|
828
|
+
# New files are implementation work too. Metadata errs toward allowing
|
|
829
|
+
# a review rather than hashing an arbitrarily large untracked artifact.
|
|
830
|
+
with contextlib.suppress(OSError):
|
|
831
|
+
info = (ctx.worktree / name).lstat()
|
|
832
|
+
parts.append(f"{name}\t{info.st_size}\t{info.st_mtime_ns}")
|
|
833
|
+
return hashlib.sha256("\n".join(parts).encode()).hexdigest()
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def untracked_entries(worktree: Path) -> set[str]:
|
|
837
|
+
"""Return untracked paths at Git's directory-grouped granularity."""
|
|
838
|
+
out = git_quiet(
|
|
839
|
+
worktree,
|
|
840
|
+
"ls-files",
|
|
841
|
+
"--others",
|
|
842
|
+
"--exclude-standard",
|
|
843
|
+
"--directory",
|
|
844
|
+
"--no-empty-directory",
|
|
845
|
+
"-z",
|
|
846
|
+
)
|
|
847
|
+
return {name for name in out.split("\0") if name}
|