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/commit.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Committing a finished run on its own branch, and saying why when that fails."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .core import RunContext, StargateError, git_quiet, print_output, run_process
|
|
10
|
+
|
|
11
|
+
COMMIT_SUBJECT_CHARS = 72
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
COMMIT_TIMEOUT_SECONDS = 600
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
COMMIT_OUTPUT_LINES = 20
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _commit_output_label(ctx: RunContext) -> str | None:
|
|
21
|
+
if ctx.mode == "fanout-task":
|
|
22
|
+
return f"task {ctx.slug}/commit"
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _labelled_output(label: str, output: str) -> str:
|
|
27
|
+
prefix = f"[{label}] "
|
|
28
|
+
chunks = output.splitlines(keepends=True)
|
|
29
|
+
return "".join(prefix + chunk for chunk in chunks) if chunks else f"[{label}]"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _print_commit_output(ctx: RunContext, output: str) -> None:
|
|
33
|
+
if not output:
|
|
34
|
+
return
|
|
35
|
+
label = _commit_output_label(ctx)
|
|
36
|
+
if label:
|
|
37
|
+
output = _labelled_output(label, output)
|
|
38
|
+
print_output(output, end="" if output.endswith("\n") else "\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _print_commit_diagnostic(ctx: RunContext, message: str) -> None:
|
|
42
|
+
label = _commit_output_label(ctx)
|
|
43
|
+
displayed = _labelled_output(label, message) if label else message
|
|
44
|
+
print_output(f"\n{displayed}", file=sys.stderr)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def commit_message(ctx: RunContext, verdict: str, test_exit: int | None) -> str:
|
|
48
|
+
task = " ".join(ctx.task.split()) or "run"
|
|
49
|
+
suffix = f" ({verdict})"
|
|
50
|
+
available = max(0, COMMIT_SUBJECT_CHARS - len("stargate: ") - len(suffix))
|
|
51
|
+
subject_task = task[:available].rstrip()
|
|
52
|
+
subject = f"stargate: {subject_task}{suffix}"
|
|
53
|
+
|
|
54
|
+
if ctx.test_command:
|
|
55
|
+
tests = (
|
|
56
|
+
f"{ctx.test_command} (exit {test_exit})"
|
|
57
|
+
if test_exit is not None else f"{ctx.test_command} (not run)"
|
|
58
|
+
)
|
|
59
|
+
elif "report-only" in ctx.test_source:
|
|
60
|
+
tests = "not run (report-only detection)"
|
|
61
|
+
else:
|
|
62
|
+
tests = "not configured"
|
|
63
|
+
|
|
64
|
+
trailers = [
|
|
65
|
+
f"Stargate-Run-Id: {ctx.run_id}",
|
|
66
|
+
f"Stargate-Verdict: {verdict}",
|
|
67
|
+
f"Stargate-Base-Ref: {ctx.base_ref}",
|
|
68
|
+
f"Stargate-Base-Commit: {ctx.base_commit}",
|
|
69
|
+
]
|
|
70
|
+
if test_exit is not None:
|
|
71
|
+
trailers.append(f"Stargate-Tests-Exit: {test_exit}")
|
|
72
|
+
return f"""\
|
|
73
|
+
{subject}
|
|
74
|
+
|
|
75
|
+
Task: {task}
|
|
76
|
+
Verdict: {verdict}
|
|
77
|
+
Tests: {tests}
|
|
78
|
+
Base: {ctx.base_ref}
|
|
79
|
+
Base commit: {ctx.base_commit}
|
|
80
|
+
|
|
81
|
+
Produced by stargate agents, committed by the orchestrator; the agents
|
|
82
|
+
themselves never run git. Plan, review and traces:
|
|
83
|
+
.stargate/runs/{ctx.run_id}/
|
|
84
|
+
|
|
85
|
+
{chr(10).join(trailers)}
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def stage_run_changes(ctx: RunContext) -> bool:
|
|
90
|
+
"""Stage agent work while excluding untracked test-created entries."""
|
|
91
|
+
excludes = [f":(exclude,literal){name}" for name in sorted(ctx.test_artifacts)]
|
|
92
|
+
run_process(
|
|
93
|
+
["git", "add", "-A", "--", ".", *excludes],
|
|
94
|
+
ctx.worktree,
|
|
95
|
+
output_label=_commit_output_label(ctx),
|
|
96
|
+
)
|
|
97
|
+
proc = subprocess.run(
|
|
98
|
+
["git", "diff", "--cached", "--quiet"],
|
|
99
|
+
cwd=str(ctx.worktree),
|
|
100
|
+
stdout=subprocess.DEVNULL,
|
|
101
|
+
stderr=subprocess.PIPE,
|
|
102
|
+
text=True,
|
|
103
|
+
)
|
|
104
|
+
if proc.returncode not in (0, 1):
|
|
105
|
+
raise StargateError(
|
|
106
|
+
f"git diff --cached --quiet failed in {ctx.worktree}: "
|
|
107
|
+
f"{proc.stderr.strip()}"
|
|
108
|
+
)
|
|
109
|
+
return proc.returncode == 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def commit_failure(ctx: RunContext, reason: str, output: str = "") -> None:
|
|
113
|
+
tail = "\n".join(output.strip().splitlines()[-COMMIT_OUTPUT_LINES:])
|
|
114
|
+
message = (
|
|
115
|
+
f"Could not commit the run's work ({reason}). The changes are intact "
|
|
116
|
+
f"in {ctx.worktree}; any successfully staged changes remain staged, "
|
|
117
|
+
"and nothing was lost. A pre-commit hook or a commit signing "
|
|
118
|
+
"configuration in this repository is the usual cause -- a hook that "
|
|
119
|
+
"rewrote files may leave those rewrites unstaged. Fix the cause and "
|
|
120
|
+
"resume -- the recorded verdict is reused, so this retries the commit "
|
|
121
|
+
"without paying for another review:\n"
|
|
122
|
+
f" stargate resume {ctx.run_id}\n"
|
|
123
|
+
"Or finish by hand with:\n"
|
|
124
|
+
f" cd {shlex.quote(str(ctx.worktree))} && git commit"
|
|
125
|
+
)
|
|
126
|
+
if tail:
|
|
127
|
+
message += "\nLast output:\n" + "\n".join(
|
|
128
|
+
f" {line}" for line in tail.splitlines()
|
|
129
|
+
)
|
|
130
|
+
ctx.commit_error = message
|
|
131
|
+
_print_commit_diagnostic(ctx, message)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def commit_run(
|
|
135
|
+
ctx: RunContext,
|
|
136
|
+
verdict: str,
|
|
137
|
+
test_exit: int | None,
|
|
138
|
+
*,
|
|
139
|
+
allow_empty: bool = False,
|
|
140
|
+
) -> str:
|
|
141
|
+
"""Make at most one commit after the run reaches a terminal verdict.
|
|
142
|
+
|
|
143
|
+
Red results are committed too: they are the results most in need of a
|
|
144
|
+
durable recovery point, and the private, unpushed branch plus verdict in
|
|
145
|
+
the message keeps the history honest. Fixer passes are one editing session,
|
|
146
|
+
so committing before the final review would create misleading checkpoints.
|
|
147
|
+
"""
|
|
148
|
+
ctx.commit_error = ""
|
|
149
|
+
if not ctx.worktree.exists():
|
|
150
|
+
return "empty"
|
|
151
|
+
|
|
152
|
+
branch = git_quiet(ctx.worktree, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
153
|
+
if branch != ctx.branch:
|
|
154
|
+
commit_failure(
|
|
155
|
+
ctx,
|
|
156
|
+
f"worktree is on {branch!r}, not the run branch {ctx.branch!r}",
|
|
157
|
+
)
|
|
158
|
+
return "failed"
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
changed = stage_run_changes(ctx)
|
|
162
|
+
except StargateError as exc:
|
|
163
|
+
commit_failure(ctx, f"staging failed: {exc}")
|
|
164
|
+
return "failed"
|
|
165
|
+
if not changed and not allow_empty:
|
|
166
|
+
return "empty"
|
|
167
|
+
|
|
168
|
+
message_path = ctx.artifacts / "commit-message.txt"
|
|
169
|
+
message_path.write_text(commit_message(ctx, verdict, test_exit))
|
|
170
|
+
output_label = _commit_output_label(ctx)
|
|
171
|
+
output_log: Path | None = None
|
|
172
|
+
if output_label:
|
|
173
|
+
# A log-backed process is tracked by the signal-safe runner. Its
|
|
174
|
+
# transcript is then printed as one labelled block and removed; the
|
|
175
|
+
# durable commit message remains the only new task artifact.
|
|
176
|
+
output_log = ctx.artifacts / ".commit-output.log"
|
|
177
|
+
output_log.unlink(missing_ok=True)
|
|
178
|
+
try:
|
|
179
|
+
proc = run_process(
|
|
180
|
+
[
|
|
181
|
+
"git",
|
|
182
|
+
"commit",
|
|
183
|
+
*(("--allow-empty",) if allow_empty else ()),
|
|
184
|
+
"-F",
|
|
185
|
+
str(message_path),
|
|
186
|
+
],
|
|
187
|
+
ctx.worktree,
|
|
188
|
+
check=False,
|
|
189
|
+
timeout=COMMIT_TIMEOUT_SECONDS,
|
|
190
|
+
log_path=output_log,
|
|
191
|
+
output_label=output_label,
|
|
192
|
+
)
|
|
193
|
+
except StargateError as exc:
|
|
194
|
+
commit_failure(ctx, str(exc))
|
|
195
|
+
return "failed"
|
|
196
|
+
finally:
|
|
197
|
+
if output_log is not None:
|
|
198
|
+
output_log.unlink(missing_ok=True)
|
|
199
|
+
if output_label:
|
|
200
|
+
_print_commit_output(ctx, proc.stdout or "")
|
|
201
|
+
if proc.returncode:
|
|
202
|
+
commit_failure(ctx, f"git exit {proc.returncode}", proc.stdout or "")
|
|
203
|
+
return "failed"
|
|
204
|
+
|
|
205
|
+
ctx.commit = git_quiet(ctx.worktree, "rev-parse", "HEAD").strip()
|
|
206
|
+
# Known test output is deliberately left untracked, so it must not be
|
|
207
|
+
# blamed on a hook. The same exclusions reveal only unexpected rewrites.
|
|
208
|
+
excludes = [f":(exclude,literal){name}" for name in sorted(ctx.test_artifacts)]
|
|
209
|
+
status = git_quiet(
|
|
210
|
+
ctx.worktree, "status", "--porcelain", "--", ".", *excludes
|
|
211
|
+
)
|
|
212
|
+
if status:
|
|
213
|
+
warning = (
|
|
214
|
+
"Warning: the commit succeeded, but a repository hook modified "
|
|
215
|
+
f"files afterward. Those changes remain uncommitted in {ctx.worktree}; "
|
|
216
|
+
"stargate will not create a second commit."
|
|
217
|
+
)
|
|
218
|
+
_print_commit_diagnostic(ctx, warning)
|
|
219
|
+
return "committed"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def terminal_commit_at_head(ctx: RunContext, verdict: str) -> str:
|
|
223
|
+
"""Recover a terminal commit made just before state persistence stopped.
|
|
224
|
+
|
|
225
|
+
A signal can arrive after ``git commit`` succeeds but before ``finish``
|
|
226
|
+
records the terminal fields in state.json. The commit trailers make that
|
|
227
|
+
narrow window distinguishable from both task commits and user commits, so
|
|
228
|
+
a resume can adopt the commit instead of adding another empty one.
|
|
229
|
+
"""
|
|
230
|
+
if not ctx.worktree.exists():
|
|
231
|
+
return ""
|
|
232
|
+
try:
|
|
233
|
+
head = git_quiet(ctx.worktree, "rev-parse", "HEAD").strip()
|
|
234
|
+
message = git_quiet(ctx.worktree, "log", "-1", "--format=%B", "HEAD")
|
|
235
|
+
except StargateError:
|
|
236
|
+
return ""
|
|
237
|
+
trailers = set(message.splitlines())
|
|
238
|
+
if (
|
|
239
|
+
f"Stargate-Run-Id: {ctx.run_id}" in trailers
|
|
240
|
+
and f"Stargate-Verdict: {verdict}" in trailers
|
|
241
|
+
):
|
|
242
|
+
return head
|
|
243
|
+
return ""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def commit_summary(ctx: RunContext, enabled: bool) -> str:
|
|
247
|
+
if not enabled:
|
|
248
|
+
return "disabled"
|
|
249
|
+
if ctx.commit_error:
|
|
250
|
+
return "FAILED"
|
|
251
|
+
if ctx.commit:
|
|
252
|
+
return ctx.commit
|
|
253
|
+
return "none (nothing to commit)"
|
stargate/config.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Everything read off disk to configure a run: the layered YAML, the agent
|
|
2
|
+
entry each role resolves to, and the prompt files."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .core import StargateError
|
|
13
|
+
|
|
14
|
+
# Per-project override. Deliberately NOT "agents.yaml": that name is common
|
|
15
|
+
# enough that a global install would silently pick up an unrelated repo's file.
|
|
16
|
+
PROJECT_CONFIG = ".stargate.yaml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ROLES = ("architect", "developer", "reviewer", "fixer")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PROMPTS = (*ROLES, "fanout")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def user_config() -> Path:
|
|
26
|
+
base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
|
|
27
|
+
return Path(os.path.expanduser(base)) / "stargate" / "agents.yaml"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_config(arg: str | None, script_dir: Path) -> list[Path]:
|
|
31
|
+
"""Config sources, most specific first."""
|
|
32
|
+
if arg:
|
|
33
|
+
# Explicit config is also the escape hatch for resuming past a broken
|
|
34
|
+
# definition, so layering anything under it would make it non-explicit.
|
|
35
|
+
return [Path(arg).expanduser().resolve()]
|
|
36
|
+
|
|
37
|
+
project = (Path.cwd() / PROJECT_CONFIG).resolve()
|
|
38
|
+
user = user_config().resolve()
|
|
39
|
+
packaged = (script_dir / "agents.yaml").resolve()
|
|
40
|
+
candidates = [path for path in (project, user) if path.exists()]
|
|
41
|
+
# Lookup never walks to a parent or follows a path from config, so a project
|
|
42
|
+
# cannot accidentally pull configuration from an unrelated repository.
|
|
43
|
+
return list(dict.fromkeys([*candidates, packaged]))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def init_prompts(script_dir: Path) -> int:
|
|
47
|
+
target = user_config().parent / "prompts"
|
|
48
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
for name in PROMPTS:
|
|
50
|
+
dest = target / f"{name}.md"
|
|
51
|
+
if dest.exists():
|
|
52
|
+
print(f"kept {dest}")
|
|
53
|
+
continue
|
|
54
|
+
dest.write_text((script_dir / "prompts" / f"{name}.md").read_text())
|
|
55
|
+
print(f"wrote {dest}")
|
|
56
|
+
print("\nEdit these to override the defaults. Delete one to fall back.")
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def init_config(script_dir: Path) -> int:
|
|
61
|
+
target = user_config()
|
|
62
|
+
if target.exists():
|
|
63
|
+
print(f"Already exists, not overwriting: {target}")
|
|
64
|
+
return 1
|
|
65
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
target.write_text((script_dir / "agents.yaml").read_text())
|
|
67
|
+
print(f"Wrote {target}\nEdit it to set test_command, models, timeouts.")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def layer_config(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
"""Merge sections by key while replacing each structured entry whole."""
|
|
73
|
+
merged = dict(base)
|
|
74
|
+
for section, value in override.items():
|
|
75
|
+
inherited = merged.get(section)
|
|
76
|
+
# A bare mapping section such as `settings:` must not erase its base.
|
|
77
|
+
# ponytail: add a delete sentinel only if an overlay needs one.
|
|
78
|
+
if isinstance(inherited, dict) and value is None:
|
|
79
|
+
continue
|
|
80
|
+
if isinstance(inherited, dict) and isinstance(value, dict):
|
|
81
|
+
merged[section] = {**inherited, **value}
|
|
82
|
+
else:
|
|
83
|
+
merged[section] = value
|
|
84
|
+
return merged
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_config(
|
|
88
|
+
paths: list[Path],
|
|
89
|
+
) -> tuple[dict[str, Any], list[tuple[Path, dict[str, Any]]]]:
|
|
90
|
+
"""Load the effective config and retain the layers that supplied it."""
|
|
91
|
+
layers: list[tuple[Path, dict[str, Any]]] = []
|
|
92
|
+
for path in paths:
|
|
93
|
+
if not path.exists():
|
|
94
|
+
raise StargateError(f"Config not found: {path}")
|
|
95
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
96
|
+
if not isinstance(data, dict):
|
|
97
|
+
raise StargateError(f"Config must be a YAML mapping: {path}")
|
|
98
|
+
layers.append((path, data))
|
|
99
|
+
|
|
100
|
+
config: dict[str, Any] = {}
|
|
101
|
+
for _, data in reversed(layers):
|
|
102
|
+
config = layer_config(config, data)
|
|
103
|
+
|
|
104
|
+
agents = config.get("agents")
|
|
105
|
+
workflow = config.get("workflow")
|
|
106
|
+
if (
|
|
107
|
+
not isinstance(agents, dict) or not agents
|
|
108
|
+
or not isinstance(workflow, dict) or not workflow
|
|
109
|
+
):
|
|
110
|
+
raise StargateError("Config must contain 'agents' and 'workflow'.")
|
|
111
|
+
if "settings" in config and not isinstance(config["settings"], dict):
|
|
112
|
+
raise StargateError("Config 'settings' must be a mapping.")
|
|
113
|
+
return config, layers
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def agent_entry(config: dict[str, Any], role: str) -> dict[str, Any]:
|
|
117
|
+
try:
|
|
118
|
+
return config["agents"][config["workflow"][role]]
|
|
119
|
+
except KeyError as exc:
|
|
120
|
+
raise StargateError(f"Invalid agent configuration for role '{role}'.") from exc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def agent_env(entry: dict[str, Any]) -> dict[str, str] | None:
|
|
124
|
+
"""The environment for one agent, or None to inherit unchanged.
|
|
125
|
+
|
|
126
|
+
A null value REMOVES the variable. That is the case worth supporting: an
|
|
127
|
+
ANTHROPIC_API_KEY exported globally shadows the CLI's own login, and
|
|
128
|
+
without this the only fix is to unset it for the whole orchestrator.
|
|
129
|
+
"""
|
|
130
|
+
declared = entry.get("env")
|
|
131
|
+
if not declared:
|
|
132
|
+
return None
|
|
133
|
+
if not isinstance(declared, dict):
|
|
134
|
+
raise StargateError("An agent's 'env' must be a mapping of names to values.")
|
|
135
|
+
env = dict(os.environ)
|
|
136
|
+
for key, value in declared.items():
|
|
137
|
+
if value is None:
|
|
138
|
+
env.pop(str(key), None)
|
|
139
|
+
else:
|
|
140
|
+
env[str(key)] = str(value)
|
|
141
|
+
return env
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def env_summary(entry: dict[str, Any]) -> str:
|
|
145
|
+
"""Which variables an agent overrides. Names only -- values are secrets."""
|
|
146
|
+
declared = entry.get("env") or {}
|
|
147
|
+
if not isinstance(declared, dict) or not declared:
|
|
148
|
+
return ""
|
|
149
|
+
return ", ".join(
|
|
150
|
+
f"{key} (unset)" if value is None else str(key)
|
|
151
|
+
for key, value in declared.items()
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_usage(transcript: str, pattern: str | None) -> int:
|
|
156
|
+
"""Tokens an agent reported spending, via a regex the CONFIG supplies.
|
|
157
|
+
|
|
158
|
+
The orchestrator cannot see inside an agent — most of a run's tokens are the
|
|
159
|
+
model reading the repo, never crossing this process. So the only usable
|
|
160
|
+
number is whatever the CLI prints, and the shape of that is the vendor's
|
|
161
|
+
business, not this file's.
|
|
162
|
+
"""
|
|
163
|
+
if not pattern:
|
|
164
|
+
return 0
|
|
165
|
+
# The last match, never the first: an agent that reads or writes test
|
|
166
|
+
# fixtures echoes strings shaped like the usage footer into its own
|
|
167
|
+
# transcript, and the CLI prints the real total once, at the end.
|
|
168
|
+
matches = list(re.finditer(pattern, transcript))
|
|
169
|
+
if not matches or not matches[-1].groups():
|
|
170
|
+
return 0
|
|
171
|
+
match = matches[-1]
|
|
172
|
+
try:
|
|
173
|
+
return int(match.group(1).replace(",", "").replace(".", "").replace("_", ""))
|
|
174
|
+
except ValueError:
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def token_cap(config: dict[str, Any]) -> int:
|
|
179
|
+
return int(config.get("settings", {}).get("max_task_tokens", 0) or 0)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def agent_command(config: dict[str, Any], role: str) -> list[str]:
|
|
183
|
+
command = agent_entry(config, role).get("command")
|
|
184
|
+
if not isinstance(command, list) or not command:
|
|
185
|
+
raise StargateError(f"Agent for role '{role}' needs a non-empty command list.")
|
|
186
|
+
return [str(x) for x in command]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
TEST_COMMAND_PLACEHOLDER = "{test_command}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# The packaged allowlist syntax uses parentheses and commas as structure and
|
|
193
|
+
# `*` as a wildcard. Interpolating any of them would grant a PATTERN broader
|
|
194
|
+
# than the one project command stargate runs, even though config is trusted.
|
|
195
|
+
TEST_COMMAND_PATTERN_UNSAFE = re.compile(r"[(),*\x00-\x1f\x7f]")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_command_grant(test_command: str) -> str | None:
|
|
199
|
+
"""The exact command safe to place in a permission pattern, if any."""
|
|
200
|
+
command = (test_command or "").strip()
|
|
201
|
+
if not command or TEST_COMMAND_PATTERN_UNSAFE.search(command):
|
|
202
|
+
return None
|
|
203
|
+
return command
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def expand_test_command(command: list[str], test_command: str) -> list[str]:
|
|
207
|
+
"""Expand {test_command}, dropping its whole option when no grant is safe.
|
|
208
|
+
|
|
209
|
+
An empty value is not safe here: it can become Bash(), an empty argv item,
|
|
210
|
+
or leave an option to consume the agent prompt as its value. The command
|
|
211
|
+
placeholder therefore belongs in an option value, never in argv[0].
|
|
212
|
+
"""
|
|
213
|
+
grant = test_command_grant(test_command)
|
|
214
|
+
expanded: list[str] = []
|
|
215
|
+
for index, part in enumerate(command):
|
|
216
|
+
if TEST_COMMAND_PLACEHOLDER not in part:
|
|
217
|
+
expanded.append(part)
|
|
218
|
+
continue
|
|
219
|
+
if index == 0:
|
|
220
|
+
raise StargateError(
|
|
221
|
+
"{test_command} cannot be used as an agent executable; put it "
|
|
222
|
+
"in an option value."
|
|
223
|
+
)
|
|
224
|
+
if grant is not None:
|
|
225
|
+
expanded.append(part.replace(TEST_COMMAND_PLACEHOLDER, grant))
|
|
226
|
+
continue
|
|
227
|
+
# An option containing the placeholder is self-contained, regardless
|
|
228
|
+
# of its spelling. A separate value may itself contain `=`, so only
|
|
229
|
+
# its position -- not that character -- identifies the option to drop.
|
|
230
|
+
if not part.startswith("-") and expanded and expanded[-1].startswith("-"):
|
|
231
|
+
expanded.pop()
|
|
232
|
+
if not expanded:
|
|
233
|
+
raise StargateError("Expanding {test_command} left an empty agent command.")
|
|
234
|
+
return expanded
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
AGENT_RETRIES_DEFAULT = 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
AGENT_RETRY_BACKOFF_DEFAULT = 10.0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def retry_settings(config: dict[str, Any]) -> tuple[int, float]:
|
|
244
|
+
settings = config.get("settings", {})
|
|
245
|
+
retries = max(0, int(settings.get("agent_retries", AGENT_RETRIES_DEFAULT) or 0))
|
|
246
|
+
backoff = max(0.0, float(
|
|
247
|
+
settings.get(
|
|
248
|
+
"agent_retry_backoff_seconds", AGENT_RETRY_BACKOFF_DEFAULT
|
|
249
|
+
) or 0
|
|
250
|
+
))
|
|
251
|
+
return retries, backoff
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def prompt_dirs(config: dict[str, Any], script_dir: Path) -> list[Path]:
|
|
255
|
+
"""Prompt sources, most specific first. Overrides are per-file: a custom
|
|
256
|
+
reviewer.md is picked up while the other three fall back to the defaults."""
|
|
257
|
+
configured = str(config.get("settings", {}).get("prompts_dir", "") or "").strip()
|
|
258
|
+
dirs = [Path(os.path.expanduser(configured)).resolve()] if configured else []
|
|
259
|
+
return [*dirs, user_config().parent / "prompts", script_dir / "prompts"]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def find_prompt(dirs: list[Path], name: str) -> Path:
|
|
263
|
+
for base in dirs:
|
|
264
|
+
candidate = base / f"{name}.md"
|
|
265
|
+
if candidate.exists():
|
|
266
|
+
return candidate
|
|
267
|
+
searched = ", ".join(str(d) for d in dirs)
|
|
268
|
+
raise StargateError(f"Prompt {name}.md not found in: {searched}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def render_prompt(dirs: list[Path], name: str, **values: str) -> str:
|
|
272
|
+
"""Substitute only the placeholders we define, in a single pass.
|
|
273
|
+
|
|
274
|
+
Not str.format: a custom prompt is free to contain JSON, CSS or an f-string
|
|
275
|
+
example, and every brace in it would otherwise have to be escaped or the
|
|
276
|
+
run dies with KeyError before a single agent starts.
|
|
277
|
+
|
|
278
|
+
One pass rather than one replace per key, because the values are agent
|
|
279
|
+
output. Replacing in sequence rescans what the previous key just inserted,
|
|
280
|
+
so a review whose own text contains `{tests}` reached the fixer with the
|
|
281
|
+
test report spliced inside the reviewer's finding.
|
|
282
|
+
"""
|
|
283
|
+
text = find_prompt(dirs, name).read_text()
|
|
284
|
+
if not values:
|
|
285
|
+
return text
|
|
286
|
+
pattern = re.compile("|".join(re.escape("{" + key + "}") for key in values))
|
|
287
|
+
# A function replacement, so a value containing backslashes or \1 is
|
|
288
|
+
# inserted literally rather than read as a group reference.
|
|
289
|
+
return pattern.sub(lambda match: values[match.group(0)[1:-1]], text)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def value_source(
|
|
293
|
+
layers: list[tuple[Path, dict[str, Any]]], section: str, key: str
|
|
294
|
+
) -> str:
|
|
295
|
+
"""The numbered layer that supplied one effective value."""
|
|
296
|
+
for index, (_, data) in enumerate(layers, 1):
|
|
297
|
+
block = data.get(section)
|
|
298
|
+
if isinstance(block, dict) and key in block:
|
|
299
|
+
return f"[{index}]"
|
|
300
|
+
return "(default)"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# Committing is ON by default. The friction this removes is a default-level
|
|
304
|
+
# problem: a run whose work exists only as a dirty worktree cannot be built on
|
|
305
|
+
# without a hand-made branch and commit, and a `git worktree remove --force`
|
|
306
|
+
# destroys it, because the artifacts hold traces and prose, not code. A flag
|
|
307
|
+
# nobody knows exists does not fix that. Unlike a detected test command, this
|
|
308
|
+
# executes nothing the user has not already sanctioned: the commit lands on a
|
|
309
|
+
# branch stargate created, in a worktree stargate created, and is only pushed
|
|
310
|
+
# with --pr in that invocation, never merged. The change is narrow -- `git status` in
|
|
311
|
+
# the worktree goes clean, while `git diff <base>` still shows every change.
|
|
312
|
+
# `commit: false` restores the old behaviour exactly.
|
|
313
|
+
SEVERITIES = ("high", "medium", "low")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def blocking_severities(config: dict[str, Any]) -> tuple[str, ...]:
|
|
317
|
+
"""Validate the opt-in policy; only an empty policy preserves reviewer authority."""
|
|
318
|
+
value = config.get("settings", {}).get("blocking_severities")
|
|
319
|
+
if value is None:
|
|
320
|
+
return ()
|
|
321
|
+
if not isinstance(value, list):
|
|
322
|
+
raise StargateError(
|
|
323
|
+
"settings.blocking_severities must be a list of severity names "
|
|
324
|
+
f"({', '.join(SEVERITIES)}), e.g. [high, medium]; got {value!r}."
|
|
325
|
+
)
|
|
326
|
+
names = []
|
|
327
|
+
for item in value:
|
|
328
|
+
name = item.strip().lower() if isinstance(item, str) else item
|
|
329
|
+
if name not in SEVERITIES:
|
|
330
|
+
raise StargateError(
|
|
331
|
+
"settings.blocking_severities contains an unknown severity "
|
|
332
|
+
f"{item!r}; valid names are {', '.join(SEVERITIES)}."
|
|
333
|
+
)
|
|
334
|
+
names.append(name)
|
|
335
|
+
return tuple(dict.fromkeys(names))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def task_sources(config: dict[str, Any]) -> list[dict[str, Any]]:
|
|
339
|
+
"""Validate sources before fetching; normalize hosts and command arguments."""
|
|
340
|
+
value = config.get("task_sources")
|
|
341
|
+
if value is None:
|
|
342
|
+
return []
|
|
343
|
+
if not isinstance(value, list):
|
|
344
|
+
raise StargateError("task_sources must be a list of source mappings.")
|
|
345
|
+
sources = []
|
|
346
|
+
for index, entry in enumerate(value):
|
|
347
|
+
label = f"task_sources[{index}]"
|
|
348
|
+
if not isinstance(entry, dict):
|
|
349
|
+
raise StargateError(f"{label} must be a mapping.")
|
|
350
|
+
hosts = entry.get("hosts")
|
|
351
|
+
if not isinstance(hosts, list) or not hosts or any(
|
|
352
|
+
not isinstance(host, str) or not host.strip() for host in hosts
|
|
353
|
+
):
|
|
354
|
+
raise StargateError(f"{label}.hosts must be a non-empty list of non-empty host names.")
|
|
355
|
+
commands = entry.get("commands")
|
|
356
|
+
if not isinstance(commands, list) or not commands:
|
|
357
|
+
raise StargateError(f"{label}.commands must be a non-empty list of commands.")
|
|
358
|
+
normalized = []
|
|
359
|
+
for command_index, command in enumerate(commands):
|
|
360
|
+
command_label = f"{label}.commands[{command_index}]"
|
|
361
|
+
if not isinstance(command, list) or not command:
|
|
362
|
+
raise StargateError(
|
|
363
|
+
f"{command_label} must be a non-empty list of command arguments; "
|
|
364
|
+
"commands takes a list of commands, not a flat argument list."
|
|
365
|
+
)
|
|
366
|
+
if any(not isinstance(part, (str, int, float)) for part in command):
|
|
367
|
+
raise StargateError(f"{command_label} arguments must be strings or numbers.")
|
|
368
|
+
argv = [str(part) for part in command]
|
|
369
|
+
if not argv[0].strip():
|
|
370
|
+
raise StargateError(f"{command_label}[0] must name an executable.")
|
|
371
|
+
normalized.append(argv)
|
|
372
|
+
if entry.get("env") is not None and not isinstance(entry["env"], dict):
|
|
373
|
+
raise StargateError(f"{label}.env must be a mapping of names to values.")
|
|
374
|
+
sources.append({**entry, "hosts": [host.lower() for host in hosts], "commands": normalized})
|
|
375
|
+
return sources
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def pull_request_command(config: dict[str, Any]) -> list[str] | None:
|
|
379
|
+
"""Validate how to publish; configuration never authorizes publication."""
|
|
380
|
+
block = config.get("pull_request")
|
|
381
|
+
if block is None:
|
|
382
|
+
return None
|
|
383
|
+
if not isinstance(block, dict):
|
|
384
|
+
raise StargateError("pull_request must be a mapping with a 'command'.")
|
|
385
|
+
command = block.get("command")
|
|
386
|
+
if not isinstance(command, list) or not command:
|
|
387
|
+
raise StargateError("pull_request.command must be a non-empty list of command arguments.")
|
|
388
|
+
if any(not isinstance(part, (str, int, float)) for part in command):
|
|
389
|
+
raise StargateError("pull_request.command arguments must be strings or numbers.")
|
|
390
|
+
argv = [str(part) for part in command]
|
|
391
|
+
if not argv[0].strip():
|
|
392
|
+
raise StargateError("pull_request.command[0] must name an executable.")
|
|
393
|
+
if not any("{branch}" in part for part in argv):
|
|
394
|
+
raise StargateError(
|
|
395
|
+
"pull_request.command must pass {branch} to the command; without it the pull "
|
|
396
|
+
"request would be opened from whatever branch is checked out."
|
|
397
|
+
)
|
|
398
|
+
if block.get("env") is not None and not isinstance(block["env"], dict):
|
|
399
|
+
raise StargateError("pull_request.env must be a mapping of names to values.")
|
|
400
|
+
return argv
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def validate_publication_request(config: dict[str, Any], *, requested: bool) -> None:
|
|
404
|
+
command = pull_request_command(config)
|
|
405
|
+
if requested:
|
|
406
|
+
if command is None:
|
|
407
|
+
raise StargateError(
|
|
408
|
+
"--pr needs a pull_request.command in your config; the config says how "
|
|
409
|
+
"to publish, the flag says whether. No agent was started."
|
|
410
|
+
)
|
|
411
|
+
if not commit_enabled(config):
|
|
412
|
+
raise StargateError(
|
|
413
|
+
"--pr requires settings.commit: true; without a commit there is "
|
|
414
|
+
"nothing to publish. No agent was started."
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def commit_enabled(config: dict[str, Any]) -> bool:
|
|
419
|
+
value = config.get("settings", {}).get("commit", True)
|
|
420
|
+
if not isinstance(value, bool):
|
|
421
|
+
raise StargateError("settings.commit must be true or false.")
|
|
422
|
+
return value
|