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 ADDED
File without changes
stargate/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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)