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