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/__init__.py
ADDED
|
File without changes
|
stargate/__main__.py
ADDED
stargate/agent.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Running one agent for one role: retries, the token budget it spends, and
|
|
2
|
+
the fingerprint that tells a repeated failure from a new one."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .config import (
|
|
11
|
+
agent_command,
|
|
12
|
+
agent_entry,
|
|
13
|
+
agent_env,
|
|
14
|
+
expand_test_command,
|
|
15
|
+
parse_usage,
|
|
16
|
+
retry_settings,
|
|
17
|
+
token_cap,
|
|
18
|
+
)
|
|
19
|
+
from .core import (
|
|
20
|
+
RunContext,
|
|
21
|
+
StargateError,
|
|
22
|
+
print_output,
|
|
23
|
+
run_process,
|
|
24
|
+
termination_requested,
|
|
25
|
+
wait_for_termination,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
FINGERPRINT_LINES = 20
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _role_label(ctx: RunContext, role: str) -> str:
|
|
32
|
+
if ctx.mode == "fanout-task":
|
|
33
|
+
return f"task {ctx.slug}/{role}"
|
|
34
|
+
return role
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def record_usage(ctx: RunContext, role: str, transcript: str) -> None:
|
|
38
|
+
"""Charge one completed attempt, including one the vendor rejected late."""
|
|
39
|
+
used = parse_usage(
|
|
40
|
+
transcript, agent_entry(ctx.config, role).get("usage_pattern")
|
|
41
|
+
)
|
|
42
|
+
ctx.tokens_used += used
|
|
43
|
+
if used:
|
|
44
|
+
cap = token_cap(ctx.config)
|
|
45
|
+
budget = f" of {cap:,}" if cap else ""
|
|
46
|
+
print_output(
|
|
47
|
+
f"\n[{_role_label(ctx, role)}] reported {used:,} tokens; "
|
|
48
|
+
f"{ctx.tokens_used:,}{budget} used so far."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def attempt_log_path(output_path: Path, attempt: int) -> Path:
|
|
53
|
+
# Attempt one keeps the historical path, so retries-off runs retain the
|
|
54
|
+
# same artifacts while later failures cannot overwrite its evidence.
|
|
55
|
+
suffix = "" if attempt == 1 else f".attempt-{attempt}"
|
|
56
|
+
return output_path.with_name(output_path.name + suffix + ".log")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def failure_fingerprint(error: StargateError, trace: str) -> tuple[str, str]:
|
|
60
|
+
"""Normalize per-attempt noise without interpreting vendor error text."""
|
|
61
|
+
# The prefix is ours and distinguishes timeout from a non-zero exit. The
|
|
62
|
+
# rest includes attempt-specific trace paths and the full command, neither
|
|
63
|
+
# of which says whether another attempt has a chance of succeeding.
|
|
64
|
+
reason = str(error).split(" (", 1)[0]
|
|
65
|
+
tail = "\n".join(trace.strip().splitlines()[-FINGERPRINT_LINES:])
|
|
66
|
+
return re.sub(r"\d+", "#", reason), re.sub(r"\d+", "#", tail)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def invoke_agent(
|
|
70
|
+
ctx: RunContext,
|
|
71
|
+
role: str,
|
|
72
|
+
prompt: str,
|
|
73
|
+
cwd: Path,
|
|
74
|
+
output_path: Path,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Run one agent and return its FINAL MESSAGE, not its stdout.
|
|
77
|
+
|
|
78
|
+
The distinction matters: `codex exec` streams the whole session — reasoning,
|
|
79
|
+
every command it ran, a token footer — to stdout. Forwarding that as {plan}
|
|
80
|
+
or {review} makes each hop pay for the previous hop's trace. An agent whose
|
|
81
|
+
command contains "{output}" is handed a file path to write its last message
|
|
82
|
+
to, and that file is what gets forwarded; its stdout is kept as a .log.
|
|
83
|
+
|
|
84
|
+
Retries stay inside this single invocation so no completed role is replayed.
|
|
85
|
+
Each attempt has its own trace, both for debugging and to count any usage
|
|
86
|
+
the failed process reported exactly once.
|
|
87
|
+
"""
|
|
88
|
+
cmd = agent_command(ctx.config, role)
|
|
89
|
+
writes_final = any("{output}" in part for part in cmd)
|
|
90
|
+
# Expand {output} first so those literal characters inside a configured
|
|
91
|
+
# test command cannot unexpectedly become a path.
|
|
92
|
+
cmd = [part.replace("{output}", str(output_path)) for part in cmd]
|
|
93
|
+
cmd = expand_test_command(cmd, ctx.test_command)
|
|
94
|
+
timeout = float(ctx.config.get("settings", {}).get("agent_timeout_seconds", 1800))
|
|
95
|
+
env = agent_env(agent_entry(ctx.config, role))
|
|
96
|
+
retries, backoff = retry_settings(ctx.config)
|
|
97
|
+
attempts = retries + 1
|
|
98
|
+
previous_failure: tuple[str, str] | None = None
|
|
99
|
+
process_label = (
|
|
100
|
+
_role_label(ctx, role) if ctx.mode == "fanout-task" else None
|
|
101
|
+
)
|
|
102
|
+
role_label = _role_label(ctx, role)
|
|
103
|
+
|
|
104
|
+
for attempt in range(1, attempts + 1):
|
|
105
|
+
if termination_requested():
|
|
106
|
+
raise StargateError("Orchestrator is terminating.")
|
|
107
|
+
log_path = attempt_log_path(output_path, attempt)
|
|
108
|
+
# Starting this attempt would replace the same path anyway. Removing
|
|
109
|
+
# it first prevents a termination race before Popen from charging an
|
|
110
|
+
# earlier invocation's transcript as this attempt's usage.
|
|
111
|
+
log_path.unlink(missing_ok=True)
|
|
112
|
+
trace_prefix = f"[{process_label}] " if process_label else ""
|
|
113
|
+
print_output(
|
|
114
|
+
f"{trace_prefix}trace: tail -f {shlex.quote(str(log_path))}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# A retry or explicitly redone stage must not inherit an earlier
|
|
118
|
+
# answer and pass the output contract after writing nothing.
|
|
119
|
+
if writes_final and output_path.exists():
|
|
120
|
+
output_path.unlink()
|
|
121
|
+
|
|
122
|
+
started = time.monotonic()
|
|
123
|
+
try:
|
|
124
|
+
proc = run_process(
|
|
125
|
+
[*cmd, prompt], cwd, timeout=timeout or None, log_path=log_path,
|
|
126
|
+
env=env, output_label=process_label,
|
|
127
|
+
)
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
# A process that cannot be started will fail the same way after a
|
|
130
|
+
# backoff; unlike an agent exit, it never made a remote request.
|
|
131
|
+
raise StargateError(
|
|
132
|
+
f"Could not start the agent for role '{role}': {exc}"
|
|
133
|
+
) from exc
|
|
134
|
+
except KeyboardInterrupt:
|
|
135
|
+
trace = log_path.read_text() if log_path.exists() else ""
|
|
136
|
+
record_usage(ctx, role, trace)
|
|
137
|
+
raise
|
|
138
|
+
except StargateError as exc:
|
|
139
|
+
trace = log_path.read_text() if log_path.exists() else ""
|
|
140
|
+
if termination_requested():
|
|
141
|
+
record_usage(ctx, role, trace)
|
|
142
|
+
raise
|
|
143
|
+
if retries:
|
|
144
|
+
record_usage(ctx, role, trace)
|
|
145
|
+
print_output(
|
|
146
|
+
f"\n[{role_label}] attempt {attempt} of {attempts} "
|
|
147
|
+
f"failed: {exc}"
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
# Keeping retries disabled must preserve the original failure
|
|
151
|
+
# path, including terminal output and token accounting.
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
if attempt == attempts:
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
fingerprint = failure_fingerprint(exc, trace)
|
|
158
|
+
if fingerprint == previous_failure:
|
|
159
|
+
remaining = attempts - attempt
|
|
160
|
+
print_output(
|
|
161
|
+
f"[{role_label}] failed identically twice; not retrying "
|
|
162
|
+
f"{remaining} more time(s)."
|
|
163
|
+
)
|
|
164
|
+
raise
|
|
165
|
+
previous_failure = fingerprint
|
|
166
|
+
|
|
167
|
+
wait = backoff * 2 ** (attempt - 1)
|
|
168
|
+
print_output(
|
|
169
|
+
f"[{role_label}] retrying in {wait:g}s "
|
|
170
|
+
f"(attempt {attempt + 1} of {attempts})."
|
|
171
|
+
)
|
|
172
|
+
if wait_for_termination(wait):
|
|
173
|
+
raise StargateError("Orchestrator is terminating.") from None
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
transcript = proc.stdout or ""
|
|
177
|
+
print_output(
|
|
178
|
+
f"\n[{role_label}] exit {proc.returncode} in "
|
|
179
|
+
f"{time.monotonic() - started:.0f}s"
|
|
180
|
+
)
|
|
181
|
+
record_usage(ctx, role, transcript)
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
if not writes_final:
|
|
185
|
+
output_path.write_text(transcript)
|
|
186
|
+
return transcript
|
|
187
|
+
|
|
188
|
+
final = output_path.read_text() if output_path.exists() else ""
|
|
189
|
+
if not final.strip():
|
|
190
|
+
raise StargateError(
|
|
191
|
+
f"Agent for role '{role}' declares {{output}} but wrote nothing to "
|
|
192
|
+
f"{output_path}. Check that its CLI supports the flag you passed."
|
|
193
|
+
)
|
|
194
|
+
return final
|
stargate/agents.yaml
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
version: 5
|
|
2
|
+
|
|
3
|
+
# claude -p --output-format text already prints only the final message, so the
|
|
4
|
+
# Claude roles need no {output} placeholder.
|
|
5
|
+
#
|
|
6
|
+
# NOT --permission-mode plan: under it Claude Code saves the plan to a file in
|
|
7
|
+
# ~/.claude/plans and prints only a summary, so the orchestrator would forward
|
|
8
|
+
# a fraction of the plan without noticing. --disallowedTools keeps the same
|
|
9
|
+
# read-only guarantee (the architect runs in the real repo, not the worktree)
|
|
10
|
+
# while leaving the answer on stdout.
|
|
11
|
+
#
|
|
12
|
+
# --disallowedTools takes <tools...>, so it would swallow the prompt stargate
|
|
13
|
+
# appends last. Ending these commands with --model is what stops it; keep a
|
|
14
|
+
# non-variadic option last in any Claude command you write here.
|
|
15
|
+
agents:
|
|
16
|
+
architect:
|
|
17
|
+
command:
|
|
18
|
+
- claude
|
|
19
|
+
- -p
|
|
20
|
+
- --output-format
|
|
21
|
+
- text
|
|
22
|
+
- --disallowedTools
|
|
23
|
+
- Edit Write NotebookEdit
|
|
24
|
+
- --model
|
|
25
|
+
- opus
|
|
26
|
+
# A prose reply proves only that credentials work. This exercises reading,
|
|
27
|
+
# which the role is allowed to do despite its editing tools being disabled.
|
|
28
|
+
probe: "Read the file {probe_file} and reply with its contents, nothing else."
|
|
29
|
+
probe_expect: read
|
|
30
|
+
# Per-agent environment, merged over the orchestrator's own. A null value
|
|
31
|
+
# REMOVES the variable: an ANTHROPIC_API_KEY exported globally shadows the
|
|
32
|
+
# CLI's claude.ai login, and this is how one agent opts out of it without
|
|
33
|
+
# unsetting it for everything else. Values are never printed by doctor.
|
|
34
|
+
# env:
|
|
35
|
+
# ANTHROPIC_API_KEY: null
|
|
36
|
+
|
|
37
|
+
# "{output}" is replaced with a file path; whatever the agent writes there is
|
|
38
|
+
# what gets forwarded to the next role. Without it, codex exec's entire
|
|
39
|
+
# session trace would become the next prompt.
|
|
40
|
+
developer:
|
|
41
|
+
command:
|
|
42
|
+
- codex
|
|
43
|
+
- exec
|
|
44
|
+
- --sandbox
|
|
45
|
+
- workspace-write
|
|
46
|
+
- --output-last-message
|
|
47
|
+
- "{output}"
|
|
48
|
+
# The implementer's real job depends on editing, so the billable probe
|
|
49
|
+
# creates a file in stargate's throwaway repository, never in yours.
|
|
50
|
+
probe: "Create the file {probe_file} containing exactly OK, then reply OK."
|
|
51
|
+
probe_expect: write
|
|
52
|
+
# codex exec ends its stdout with "tokens used\n7,967".
|
|
53
|
+
usage_pattern: 'tokens used\s+([\d,]+)'
|
|
54
|
+
|
|
55
|
+
reviewer:
|
|
56
|
+
command:
|
|
57
|
+
- claude
|
|
58
|
+
- -p
|
|
59
|
+
- --output-format
|
|
60
|
+
- text
|
|
61
|
+
- --disallowedTools
|
|
62
|
+
- Edit Write NotebookEdit
|
|
63
|
+
# Print mode otherwise denies arbitrary execution, leaving the reviewer
|
|
64
|
+
# able only to trust the pasted test report. This grant follows the exact
|
|
65
|
+
# command stargate runs and is dropped when none was approved. It is not
|
|
66
|
+
# on the architect because that role runs in the real repository.
|
|
67
|
+
- --allowedTools
|
|
68
|
+
- Bash({test_command})
|
|
69
|
+
- --model
|
|
70
|
+
- opus
|
|
71
|
+
probe: "Read the file {probe_file} and reply with its contents, nothing else."
|
|
72
|
+
probe_expect: read
|
|
73
|
+
|
|
74
|
+
fixer:
|
|
75
|
+
command:
|
|
76
|
+
- codex
|
|
77
|
+
- exec
|
|
78
|
+
- --sandbox
|
|
79
|
+
- workspace-write
|
|
80
|
+
- --output-last-message
|
|
81
|
+
- "{output}"
|
|
82
|
+
probe: "Create the file {probe_file} containing exactly OK, then reply OK."
|
|
83
|
+
probe_expect: write
|
|
84
|
+
# codex exec ends its stdout with "tokens used\n7,967".
|
|
85
|
+
usage_pattern: 'tokens used\s+([\d,]+)'
|
|
86
|
+
|
|
87
|
+
workflow:
|
|
88
|
+
architect: architect
|
|
89
|
+
developer: developer
|
|
90
|
+
reviewer: reviewer
|
|
91
|
+
fixer: fixer
|
|
92
|
+
|
|
93
|
+
settings:
|
|
94
|
+
max_review_loops: 2
|
|
95
|
+
|
|
96
|
+
# Fan-out is opt-in with `stargate run --fan-out`. The architect may create
|
|
97
|
+
# at most this many DAG nodes, and no more than this many ready nodes run at
|
|
98
|
+
# once. Parallel agents may overshoot max_task_tokens together before the
|
|
99
|
+
# scheduler gets control back.
|
|
100
|
+
max_fanout_tasks: 8
|
|
101
|
+
max_parallel_tasks: 2
|
|
102
|
+
|
|
103
|
+
# Empty means: don't run a test command automatically or grant it to an
|
|
104
|
+
# agent command that declares {test_command}.
|
|
105
|
+
# Examples:
|
|
106
|
+
# test_command: "pytest -q"
|
|
107
|
+
# test_command: "swift test"
|
|
108
|
+
# test_command: "npm test"
|
|
109
|
+
test_command: ""
|
|
110
|
+
|
|
111
|
+
# When test_command is empty, inspect the repository in this order: a Make
|
|
112
|
+
# test target, package.json script, Cargo, Go, Swift, then explicit pytest
|
|
113
|
+
# signals. All matches are shown. "report" is deliberately safest because a
|
|
114
|
+
# detected command is one the user never approved; "auto" runs the first
|
|
115
|
+
# match after every developer/fixer pass, and "off" skips detection.
|
|
116
|
+
test_command_detection: report
|
|
117
|
+
|
|
118
|
+
# Worktrees live outside the repository by default:
|
|
119
|
+
# <repo-parent>/.stargate-worktrees/<repo-name>/<run-id>
|
|
120
|
+
worktree_root: ""
|
|
121
|
+
|
|
122
|
+
# Directory of custom <role>.md prompts. Overrides are per-file: drop a
|
|
123
|
+
# single reviewer.md here and the other four still use the defaults.
|
|
124
|
+
# Lookup order: this, then ~/.config/stargate/prompts/, then the packaged
|
|
125
|
+
# prompts. A relative path resolves against the repo you run in.
|
|
126
|
+
# See `stargate init-prompts` and `stargate doctor`.
|
|
127
|
+
prompts_dir: ""
|
|
128
|
+
|
|
129
|
+
# Stop the run once the agents have reported this many tokens in total.
|
|
130
|
+
# 0 or unset = no limit. Only counts what an agent's usage_pattern extracts,
|
|
131
|
+
# and is only checked BETWEEN phases -- a single runaway invocation still
|
|
132
|
+
# overshoots. For a real in-flight cap, put a vendor flag in the command:
|
|
133
|
+
# claude has --max-budget-usd, codex has no equivalent today.
|
|
134
|
+
max_task_tokens: 0
|
|
135
|
+
|
|
136
|
+
# A probe is meant to be a quick liveness check, so it does not inherit the
|
|
137
|
+
# much longer agent timeout.
|
|
138
|
+
probe_timeout_seconds: 120
|
|
139
|
+
|
|
140
|
+
# Hard limits so a stuck agent or test suite cannot hang the run forever.
|
|
141
|
+
agent_timeout_seconds: 1800
|
|
142
|
+
test_timeout_seconds: 900
|
|
143
|
+
|
|
144
|
+
# Retries apply to non-zero agent exits and timeouts. Each wait doubles, and
|
|
145
|
+
# every attempt keeps its own trace and contributes any reported token usage.
|
|
146
|
+
# 0 retries preserves the historical single-attempt behavior.
|
|
147
|
+
agent_retries: 0
|
|
148
|
+
agent_retry_backoff_seconds: 10
|
stargate/cli.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .config import (
|
|
12
|
+
PROJECT_CONFIG,
|
|
13
|
+
commit_enabled,
|
|
14
|
+
init_config,
|
|
15
|
+
init_prompts,
|
|
16
|
+
load_config,
|
|
17
|
+
resolve_config,
|
|
18
|
+
validate_publication_request,
|
|
19
|
+
)
|
|
20
|
+
from .core import StargateError, Terminated, repo_root, terminate_active_processes
|
|
21
|
+
from .doctor import doctor
|
|
22
|
+
from .run import REDOABLE_STAGES, clean_runs, list_runs
|
|
23
|
+
from .source import fetch_task
|
|
24
|
+
from .stages import orchestrate
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _positive_int(value: str) -> int:
|
|
28
|
+
try:
|
|
29
|
+
parsed = int(value)
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise argparse.ArgumentTypeError("must be a positive integer") from exc
|
|
32
|
+
if parsed < 1:
|
|
33
|
+
raise argparse.ArgumentTypeError("must be a positive integer")
|
|
34
|
+
return parsed
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _validate_run_arguments(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
38
|
+
"""Reject run-only flag combinations before reserving any run artifacts."""
|
|
39
|
+
if args.from_url is not None and args.task is not None:
|
|
40
|
+
parser.error("--from cannot be combined with a task argument")
|
|
41
|
+
if args.from_url is None and not args.task:
|
|
42
|
+
parser.error("a task description or --from URL is required")
|
|
43
|
+
if args.fan_out and args.no_commit:
|
|
44
|
+
parser.error("--no-commit cannot be combined with --fan-out")
|
|
45
|
+
if args.pr and args.no_commit:
|
|
46
|
+
parser.error("--pr cannot be combined with --no-commit")
|
|
47
|
+
if not args.fan_out and args.max_parallel_tasks is not None:
|
|
48
|
+
parser.error("--max-parallel-tasks requires --fan-out")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _saved_run_mode(repo: Path, run_id: str) -> str | None:
|
|
52
|
+
"""Read enough state for flag validation; the run loader owns bad-state errors."""
|
|
53
|
+
state_path = repo / ".stargate" / "runs" / run_id / "state.json"
|
|
54
|
+
try:
|
|
55
|
+
state = json.loads(state_path.read_text())
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
return None
|
|
58
|
+
if not isinstance(state, dict):
|
|
59
|
+
return None
|
|
60
|
+
mode = state["mode"] if "mode" in state else "linear"
|
|
61
|
+
return mode if mode in ("linear", "fanout") else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _validate_resume_arguments(
|
|
65
|
+
parser: argparse.ArgumentParser,
|
|
66
|
+
args: argparse.Namespace,
|
|
67
|
+
repo: Path,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Reject mode-specific resume flags when saved state identifies the mode."""
|
|
70
|
+
if args.pr and args.no_commit:
|
|
71
|
+
parser.error("--pr cannot be combined with --no-commit")
|
|
72
|
+
mode = _saved_run_mode(repo, args.run_id)
|
|
73
|
+
if mode == "fanout":
|
|
74
|
+
if args.redo:
|
|
75
|
+
parser.error("--redo is not supported for fan-out runs")
|
|
76
|
+
if args.no_commit:
|
|
77
|
+
parser.error("--no-commit is not supported for fan-out runs")
|
|
78
|
+
elif mode == "linear" and args.max_parallel_tasks is not None:
|
|
79
|
+
parser.error("--max-parallel-tasks is only supported for fan-out runs")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
83
|
+
parser = argparse.ArgumentParser(
|
|
84
|
+
prog="stargate",
|
|
85
|
+
description="Tiny vendor-agnostic multi-agent orchestrator.",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--config",
|
|
89
|
+
default=None,
|
|
90
|
+
help=f"Complete standalone config. Without it, ./{PROJECT_CONFIG}, "
|
|
91
|
+
"the user config and packaged defaults are layered.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
95
|
+
|
|
96
|
+
doctor_parser = sub.add_parser(
|
|
97
|
+
"doctor", help="Check local CLI dependencies and configuration."
|
|
98
|
+
)
|
|
99
|
+
doctor_parser.add_argument(
|
|
100
|
+
"--probe", action="store_true",
|
|
101
|
+
help="Make one real, potentially billable call to each unique agent.",
|
|
102
|
+
)
|
|
103
|
+
sub.add_parser(
|
|
104
|
+
"init-config",
|
|
105
|
+
help="Copy the packaged agents.yaml to ~/.config/stargate/agents.yaml.",
|
|
106
|
+
)
|
|
107
|
+
sub.add_parser(
|
|
108
|
+
"init-prompts",
|
|
109
|
+
help="Copy the packaged prompts to ~/.config/stargate/prompts/ so they "
|
|
110
|
+
"can be edited without touching the install.",
|
|
111
|
+
)
|
|
112
|
+
sub.add_parser(
|
|
113
|
+
"list", aliases=["runs"],
|
|
114
|
+
help="List the runs recorded in this repository, newest first."
|
|
115
|
+
)
|
|
116
|
+
clean = sub.add_parser(
|
|
117
|
+
"clean", help="Remove a run's merged branches, clean worktrees and artifacts."
|
|
118
|
+
)
|
|
119
|
+
clean.add_argument("run_id", nargs="?", help="Run ID shown by 'stargate list'.")
|
|
120
|
+
clean.add_argument(
|
|
121
|
+
"--all", action="store_true", dest="all_runs",
|
|
122
|
+
help="Clean every recorded run that passes the safety checks, skipping the rest.",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
run = sub.add_parser("run", help="Plan, implement, review and fix a task.")
|
|
126
|
+
run.add_argument("task", nargs="?", help="Feature/bug/task description.")
|
|
127
|
+
run.add_argument(
|
|
128
|
+
"--from", dest="from_url", metavar="URL",
|
|
129
|
+
help="Read the task from the configured source for this URL's host. "
|
|
130
|
+
"Cannot be combined with a task argument; rejected before a run is created.",
|
|
131
|
+
)
|
|
132
|
+
run.add_argument(
|
|
133
|
+
"--fan-out",
|
|
134
|
+
action="store_true",
|
|
135
|
+
help="Split the task into a DAG and run ready tasks concurrently. "
|
|
136
|
+
"Requires settings.commit: true and cannot be combined with --no-commit.",
|
|
137
|
+
)
|
|
138
|
+
run.add_argument(
|
|
139
|
+
"--base-ref",
|
|
140
|
+
default=None,
|
|
141
|
+
help="Git ref to branch from. Defaults to the current branch/ref.",
|
|
142
|
+
)
|
|
143
|
+
run.add_argument(
|
|
144
|
+
"--name",
|
|
145
|
+
default=None,
|
|
146
|
+
help="Short name for the branch and run id, e.g. --name 'passkey auth'. "
|
|
147
|
+
"Overrides the name the architect suggests.",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
resume = sub.add_parser(
|
|
151
|
+
"resume",
|
|
152
|
+
help="Continue a run that failed partway, reusing its plan, worktree, "
|
|
153
|
+
"config and prompts.",
|
|
154
|
+
description="Continue a run that failed partway, reusing its saved state. "
|
|
155
|
+
"Fan-out mode is restored automatically; resume has no --fan-out switch.",
|
|
156
|
+
)
|
|
157
|
+
resume.add_argument("run_id", help="Run ID, as printed by the original run.")
|
|
158
|
+
resume.add_argument(
|
|
159
|
+
"--redo", action="append", default=[], choices=REDOABLE_STAGES,
|
|
160
|
+
metavar="STAGE",
|
|
161
|
+
help="Run this completed stage again instead of skipping it "
|
|
162
|
+
f"({', '.join(REDOABLE_STAGES)}). Repeatable. Linear runs only; "
|
|
163
|
+
"fan-out resumes reject this option.",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
run.add_argument(
|
|
167
|
+
"--no-commit",
|
|
168
|
+
action="store_true",
|
|
169
|
+
help="Leave a linear run's work uncommitted in its worktree. Cannot be "
|
|
170
|
+
"combined with --fan-out; that error is reported before a run is created.",
|
|
171
|
+
)
|
|
172
|
+
resume.add_argument(
|
|
173
|
+
"--no-commit",
|
|
174
|
+
action="store_true",
|
|
175
|
+
help="Leave a resumed linear run's work uncommitted in its worktree. "
|
|
176
|
+
"Fan-out resumes reject this option.",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
for parser_ in (run, resume):
|
|
180
|
+
parser_.add_argument(
|
|
181
|
+
"--pr", action="store_true",
|
|
182
|
+
help="After an APPROVED result, push the run's branch and open a pull "
|
|
183
|
+
"request with pull_request.command. Never implied by configuration; "
|
|
184
|
+
"resume requires it again. Resuming a finished run re-runs review at "
|
|
185
|
+
"token cost and may replace the recorded verdict.",
|
|
186
|
+
)
|
|
187
|
+
parser_.add_argument(
|
|
188
|
+
"--max-review-loops",
|
|
189
|
+
type=int,
|
|
190
|
+
default=None,
|
|
191
|
+
help="Override settings.max_review_loops.",
|
|
192
|
+
)
|
|
193
|
+
parser_.add_argument(
|
|
194
|
+
"--max-parallel-tasks",
|
|
195
|
+
type=_positive_int,
|
|
196
|
+
default=None,
|
|
197
|
+
help=(
|
|
198
|
+
"Override settings.max_parallel_tasks. Requires --fan-out; linear "
|
|
199
|
+
"runs reject this option."
|
|
200
|
+
if parser_ is run
|
|
201
|
+
else "Override settings.max_parallel_tasks when resuming a fan-out "
|
|
202
|
+
"run; linear resumes reject this option."
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
return parser
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def install_signal_handlers() -> None:
|
|
209
|
+
"""Turn catchable termination into the existing resumable failure path."""
|
|
210
|
+
handled = [
|
|
211
|
+
signum for name in ("SIGINT", "SIGTERM", "SIGHUP")
|
|
212
|
+
if (signum := getattr(signal, name, None)) is not None
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
def terminate(signum: int, _frame: Any) -> None:
|
|
216
|
+
# One shot lets a second signal terminate even if cleanup gets stuck.
|
|
217
|
+
for handled_signum in handled:
|
|
218
|
+
signal.signal(handled_signum, signal.SIG_DFL)
|
|
219
|
+
terminate_active_processes()
|
|
220
|
+
raise Terminated(signum)
|
|
221
|
+
|
|
222
|
+
for signum in handled:
|
|
223
|
+
signal.signal(signum, terminate)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main() -> int:
|
|
227
|
+
script_dir = Path(__file__).resolve().parent
|
|
228
|
+
parser = build_parser()
|
|
229
|
+
args = parser.parse_args()
|
|
230
|
+
|
|
231
|
+
if args.command == "run":
|
|
232
|
+
_validate_run_arguments(parser, args)
|
|
233
|
+
|
|
234
|
+
if args.command == "init-config":
|
|
235
|
+
return init_config(script_dir)
|
|
236
|
+
if args.command == "init-prompts":
|
|
237
|
+
return init_prompts(script_dir)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
if args.command in ("run", "resume"):
|
|
241
|
+
# The shared handler also kills agents owned by fan-out workers.
|
|
242
|
+
install_signal_handlers()
|
|
243
|
+
|
|
244
|
+
if args.command == "resume":
|
|
245
|
+
_validate_resume_arguments(parser, args, repo_root(Path.cwd()))
|
|
246
|
+
|
|
247
|
+
if args.command in ("list", "runs"):
|
|
248
|
+
return list_runs(repo_root(Path.cwd()))
|
|
249
|
+
if args.command == "clean":
|
|
250
|
+
return clean_runs(repo_root(Path.cwd()), args.run_id, args.all_runs)
|
|
251
|
+
|
|
252
|
+
config_paths = resolve_config(args.config, script_dir)
|
|
253
|
+
config, layers = load_config(config_paths)
|
|
254
|
+
if args.command == "doctor":
|
|
255
|
+
return doctor(
|
|
256
|
+
config,
|
|
257
|
+
layers,
|
|
258
|
+
script_dir,
|
|
259
|
+
probe=args.probe,
|
|
260
|
+
explicit_config=args.config is not None,
|
|
261
|
+
)
|
|
262
|
+
if args.command in ("run", "resume"):
|
|
263
|
+
if (
|
|
264
|
+
args.command == "run"
|
|
265
|
+
and args.fan_out
|
|
266
|
+
and not commit_enabled(config)
|
|
267
|
+
):
|
|
268
|
+
raise StargateError(
|
|
269
|
+
"Fan-out requires settings.commit: true; no run was created."
|
|
270
|
+
)
|
|
271
|
+
if args.command == "run":
|
|
272
|
+
validate_publication_request(config, requested=args.pr)
|
|
273
|
+
if args.from_url is not None:
|
|
274
|
+
args.task = fetch_task(config, args.from_url, Path.cwd())
|
|
275
|
+
print(f"Task read from {args.from_url} ({len(args.task.splitlines())} lines)")
|
|
276
|
+
return orchestrate(args, script_dir, config)
|
|
277
|
+
parser.error("Unknown command")
|
|
278
|
+
return 2
|
|
279
|
+
except StargateError as exc:
|
|
280
|
+
print(f"\nERROR: {exc}", file=sys.stderr)
|
|
281
|
+
return 1
|
|
282
|
+
except KeyboardInterrupt as exc:
|
|
283
|
+
signum = getattr(exc, "signum", signal.SIGINT)
|
|
284
|
+
message = "Interrupted" if signum == signal.SIGINT else "Terminated"
|
|
285
|
+
print(f"\n{message}.", file=sys.stderr)
|
|
286
|
+
return 128 + int(signum)
|