specromancy-engine 0.1.3__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.
Files changed (63) hide show
  1. specromancy_engine/__init__.py +32 -0
  2. specromancy_engine/__main__.py +8 -0
  3. specromancy_engine/_adapters/__init__.py +1 -0
  4. specromancy_engine/_adapters/adapter_contracts.py +81 -0
  5. specromancy_engine/_adapters/adapter_ownership.py +302 -0
  6. specromancy_engine/_adapters/adapter_rendering.py +218 -0
  7. specromancy_engine/_adapters/adapter_sources.py +140 -0
  8. specromancy_engine/_cli/__init__.py +1 -0
  9. specromancy_engine/_cli/cli_commands.py +136 -0
  10. specromancy_engine/_cli/pipeline_selection.py +28 -0
  11. specromancy_engine/_config/__init__.py +1 -0
  12. specromancy_engine/_config/config_errors.py +57 -0
  13. specromancy_engine/_config/config_fields.py +228 -0
  14. specromancy_engine/_config/config_loader.py +246 -0
  15. specromancy_engine/_config/config_models.py +97 -0
  16. specromancy_engine/_config/config_phase_parser.py +497 -0
  17. specromancy_engine/_config/config_serialization.py +76 -0
  18. specromancy_engine/_engine/__init__.py +1 -0
  19. specromancy_engine/_engine/approval_service.py +278 -0
  20. specromancy_engine/_engine/approvals.py +248 -0
  21. specromancy_engine/_engine/command_decisions.py +81 -0
  22. specromancy_engine/_engine/engine_errors.py +22 -0
  23. specromancy_engine/_engine/provenance.py +58 -0
  24. specromancy_engine/_engine/responses.py +129 -0
  25. specromancy_engine/_engine/validation_service.py +112 -0
  26. specromancy_engine/_runs/__init__.py +1 -0
  27. specromancy_engine/_runs/run_errors.py +51 -0
  28. specromancy_engine/_runs/run_identity.py +66 -0
  29. specromancy_engine/_runs/run_persistence.py +303 -0
  30. specromancy_engine/_runs/run_records.py +159 -0
  31. specromancy_engine/_runs/run_validation.py +241 -0
  32. specromancy_engine/_runs/visit_preparation.py +61 -0
  33. specromancy_engine/_runs/visit_transitions.py +311 -0
  34. specromancy_engine/_version.py +18 -0
  35. specromancy_engine/actions.py +118 -0
  36. specromancy_engine/adapters.py +97 -0
  37. specromancy_engine/artifacts.py +303 -0
  38. specromancy_engine/cli.py +338 -0
  39. specromancy_engine/commands.py +140 -0
  40. specromancy_engine/config.py +63 -0
  41. specromancy_engine/contracts.py +20 -0
  42. specromancy_engine/engine.py +423 -0
  43. specromancy_engine/errors.py +42 -0
  44. specromancy_engine/exit_codes.py +53 -0
  45. specromancy_engine/git.py +264 -0
  46. specromancy_engine/graph.py +321 -0
  47. specromancy_engine/hashing.py +95 -0
  48. specromancy_engine/locking.py +107 -0
  49. specromancy_engine/registry.py +134 -0
  50. specromancy_engine/run_store.py +643 -0
  51. specromancy_engine/schema_validation.py +178 -0
  52. specromancy_engine/schemas/event.schema.json +34 -0
  53. specromancy_engine/schemas/pipeline.schema.json +154 -0
  54. specromancy_engine/schemas/pipelines.schema.json +38 -0
  55. specromancy_engine/schemas/run.schema.json +152 -0
  56. specromancy_engine/status.py +106 -0
  57. specromancy_engine/validation.py +241 -0
  58. specromancy_engine-0.1.3.dist-info/METADATA +122 -0
  59. specromancy_engine-0.1.3.dist-info/RECORD +63 -0
  60. specromancy_engine-0.1.3.dist-info/WHEEL +5 -0
  61. specromancy_engine-0.1.3.dist-info/entry_points.txt +2 -0
  62. specromancy_engine-0.1.3.dist-info/licenses/LICENSE +21 -0
  63. specromancy_engine-0.1.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,303 @@
1
+ """Immutable run-artifact addressing and integrity verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ._config.config_models import PhaseConfig, PipelineConfig
11
+ from .errors import SpecromancyError
12
+ from .exit_codes import ExitCode
13
+ from .hashing import normalize_relative_path, resolve_relative_path, sha256_bytes, sha256_file
14
+
15
+
16
+ class ArtifactError(SpecromancyError):
17
+ """An expected missing, unsafe, or inconsistent artifact failure."""
18
+
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ *,
23
+ diagnostic_code: str,
24
+ details: dict[str, Any] | None = None,
25
+ not_found: bool = False,
26
+ ) -> None:
27
+ values = {"error_code": diagnostic_code}
28
+ if details:
29
+ values.update(details)
30
+ super().__init__(
31
+ ExitCode.NOT_FOUND if not_found else ExitCode.INTERNAL_ERROR,
32
+ message,
33
+ values,
34
+ )
35
+ self.diagnostic_code = diagnostic_code
36
+
37
+
38
+ def render_output_path(
39
+ pipeline: PipelineConfig, phase: PhaseConfig, visit_number: int
40
+ ) -> str:
41
+ """Render a phase's globally addressed output path inside a run."""
42
+
43
+ if visit_number < 1:
44
+ raise ValueError("visit number must be positive")
45
+ try:
46
+ output_name = phase.output_name.format(
47
+ visit=visit_number,
48
+ phase=phase.id,
49
+ output_name="output.md",
50
+ )
51
+ rendered = pipeline.artifact_pattern.format(
52
+ visit=visit_number,
53
+ phase=phase.id,
54
+ output_name=output_name,
55
+ )
56
+ return normalize_relative_path(rendered)
57
+ except (KeyError, IndexError, ValueError) as exc:
58
+ raise ArtifactError(
59
+ f"could not render output path for phase {phase.id!r}: {exc}",
60
+ diagnostic_code="invalid-output-path",
61
+ details={"phase": phase.id, "visit_number": visit_number},
62
+ ) from exc
63
+
64
+
65
+ def artifact_record(
66
+ run_directory: Path,
67
+ path: str,
68
+ *,
69
+ reference: str | None = None,
70
+ ) -> dict[str, Any]:
71
+ """Create a literal path/hash record for an existing regular artifact."""
72
+
73
+ resolved = _required_regular_artifact(run_directory, path)
74
+ record: dict[str, Any] = {
75
+ "path": normalize_relative_path(path),
76
+ "sha256": sha256_file(resolved),
77
+ }
78
+ if reference is not None:
79
+ record["reference"] = reference
80
+ return record
81
+
82
+
83
+ def resolve_input_reference(
84
+ reference: str,
85
+ manifest: dict[str, Any],
86
+ run_directory: Path,
87
+ ) -> dict[str, Any]:
88
+ """Resolve a symbolic input to a completed, immutable artifact record."""
89
+
90
+ source: dict[str, Any] | None = None
91
+ if reference == "request":
92
+ value = manifest.get("request")
93
+ if isinstance(value, dict):
94
+ source = value
95
+ elif reference.startswith("latest:"):
96
+ phase_id = reference.removeprefix("latest:")
97
+ if not phase_id:
98
+ return _invalid_reference(reference)
99
+ for visit in reversed(manifest.get("visits", [])):
100
+ if (
101
+ isinstance(visit, dict)
102
+ and visit.get("phase_id") == phase_id
103
+ and visit.get("status") == "completed"
104
+ ):
105
+ value = visit.get("output")
106
+ if isinstance(value, dict):
107
+ source = value
108
+ break
109
+ elif reference.startswith("visit:"):
110
+ raw_number = reference.removeprefix("visit:")
111
+ if not raw_number.isascii() or not raw_number.isdigit() or int(raw_number) < 1:
112
+ return _invalid_reference(reference)
113
+ number = int(raw_number)
114
+ for visit in manifest.get("visits", []):
115
+ if (
116
+ isinstance(visit, dict)
117
+ and visit.get("ordinal") == number
118
+ and visit.get("status") == "completed"
119
+ ):
120
+ value = visit.get("output")
121
+ if isinstance(value, dict):
122
+ source = value
123
+ break
124
+ else:
125
+ return _invalid_reference(reference)
126
+
127
+ if source is None:
128
+ raise ArtifactError(
129
+ f"artifact reference is not available: {reference}",
130
+ diagnostic_code="artifact-not-found",
131
+ details={"reference": reference},
132
+ not_found=True,
133
+ )
134
+ path = source.get("path")
135
+ expected_hash = source.get("sha256")
136
+ if not isinstance(path, str) or not isinstance(expected_hash, str):
137
+ raise ArtifactError(
138
+ f"artifact record for {reference!r} is incomplete",
139
+ diagnostic_code="invalid-artifact-record",
140
+ details={"reference": reference},
141
+ )
142
+ actual = artifact_record(run_directory, path, reference=reference)
143
+ if actual["sha256"] != expected_hash:
144
+ raise ArtifactError(
145
+ f"artifact changed after it became immutable: {path}",
146
+ diagnostic_code="artifact-hash-mismatch",
147
+ details={
148
+ "path": path,
149
+ "expected": expected_hash,
150
+ "actual": actual["sha256"],
151
+ },
152
+ )
153
+ return actual
154
+
155
+
156
+ def verify_manifest_artifacts(
157
+ manifest: dict[str, Any], run_directory: Path
158
+ ) -> tuple[dict[str, Any], ...]:
159
+ """Verify the request and every completed visit output from disk alone."""
160
+
161
+ records: list[dict[str, Any]] = []
162
+ request = manifest.get("request")
163
+ if not isinstance(request, dict):
164
+ raise ArtifactError(
165
+ "run manifest has no request artifact record",
166
+ diagnostic_code="invalid-artifact-record",
167
+ )
168
+ records.append(_verify_record(request, run_directory, "request"))
169
+ for visit in manifest.get("visits", []):
170
+ if not isinstance(visit, dict) or visit.get("status") != "completed":
171
+ continue
172
+ records.append(
173
+ _verify_record(
174
+ visit.get("output"),
175
+ run_directory,
176
+ f"visit:{visit.get('ordinal')}",
177
+ )
178
+ )
179
+ return tuple(records)
180
+
181
+
182
+ def write_artifact_atomic(
183
+ run_directory: Path,
184
+ path: str,
185
+ content: str | bytes,
186
+ *,
187
+ replace: bool = False,
188
+ ) -> str:
189
+ """Atomically write an artifact, refusing accidental replacement by default."""
190
+
191
+ normalized = normalize_relative_path(path)
192
+ try:
193
+ target = resolve_relative_path(run_directory, normalized)
194
+ except (OSError, ValueError) as exc:
195
+ raise ArtifactError(
196
+ f"artifact path escapes the run directory: {path!r}",
197
+ diagnostic_code="unsafe-artifact-path",
198
+ details={"path": path},
199
+ ) from exc
200
+ if target.exists() and not replace:
201
+ raise ArtifactError(
202
+ f"artifact already exists and will not be overwritten: {normalized}",
203
+ diagnostic_code="artifact-already-exists",
204
+ details={"path": normalized},
205
+ )
206
+ target.parent.mkdir(parents=True, exist_ok=True)
207
+ # Re-resolve after creating parents so a pre-existing symlink cannot redirect us.
208
+ try:
209
+ target = resolve_relative_path(run_directory, normalized)
210
+ except (OSError, ValueError) as exc:
211
+ raise ArtifactError(
212
+ f"artifact path escapes the run directory: {path!r}",
213
+ diagnostic_code="unsafe-artifact-path",
214
+ details={"path": path},
215
+ ) from exc
216
+ payload = content.encode("utf-8") if isinstance(content, str) else content
217
+ descriptor, temporary_name = tempfile.mkstemp(
218
+ dir=target.parent, prefix=f".{target.name}.", suffix=".tmp"
219
+ )
220
+ temporary = Path(temporary_name)
221
+ try:
222
+ with os.fdopen(descriptor, "wb") as stream:
223
+ stream.write(payload)
224
+ stream.flush()
225
+ os.fsync(stream.fileno())
226
+ if target.exists() and not replace:
227
+ raise ArtifactError(
228
+ f"artifact already exists and will not be overwritten: {normalized}",
229
+ diagnostic_code="artifact-already-exists",
230
+ details={"path": normalized},
231
+ )
232
+ os.replace(temporary, target)
233
+ _fsync_directory(target.parent)
234
+ finally:
235
+ temporary.unlink(missing_ok=True)
236
+ return sha256_bytes(payload)
237
+
238
+
239
+ def _invalid_reference(reference: str) -> dict[str, Any]:
240
+ raise ArtifactError(
241
+ f"invalid artifact reference: {reference!r}",
242
+ diagnostic_code="invalid-artifact-reference",
243
+ details={"reference": reference},
244
+ not_found=True,
245
+ )
246
+
247
+
248
+ def _required_regular_artifact(run_directory: Path, path: str) -> Path:
249
+ try:
250
+ normalized = normalize_relative_path(path)
251
+ resolved = resolve_relative_path(run_directory, normalized, must_exist=True)
252
+ except (OSError, ValueError) as exc:
253
+ raise ArtifactError(
254
+ f"artifact path is missing or unsafe: {path!r}",
255
+ diagnostic_code="unsafe-artifact-path",
256
+ details={"path": path},
257
+ not_found=True,
258
+ ) from exc
259
+ literal = run_directory / normalized
260
+ if literal.is_symlink() or not resolved.is_file():
261
+ raise ArtifactError(
262
+ f"artifact is not a regular file: {path!r}",
263
+ diagnostic_code="invalid-artifact-type",
264
+ details={"path": path},
265
+ )
266
+ return resolved
267
+
268
+
269
+ def _verify_record(value: Any, run_directory: Path, reference: str) -> dict[str, Any]:
270
+ if not isinstance(value, dict):
271
+ raise ArtifactError(
272
+ f"artifact record for {reference!r} is invalid",
273
+ diagnostic_code="invalid-artifact-record",
274
+ details={"reference": reference},
275
+ )
276
+ path = value.get("path")
277
+ expected = value.get("sha256")
278
+ if not isinstance(path, str) or not isinstance(expected, str):
279
+ raise ArtifactError(
280
+ f"artifact record for {reference!r} is incomplete",
281
+ diagnostic_code="invalid-artifact-record",
282
+ details={"reference": reference},
283
+ )
284
+ actual = artifact_record(run_directory, path, reference=reference)
285
+ if actual["sha256"] != expected:
286
+ raise ArtifactError(
287
+ f"artifact changed after it became immutable: {path}",
288
+ diagnostic_code="artifact-hash-mismatch",
289
+ details={"path": path, "expected": expected, "actual": actual["sha256"]},
290
+ )
291
+ return actual
292
+
293
+
294
+ def _fsync_directory(path: Path) -> None:
295
+ descriptor = os.open(path, os.O_RDONLY)
296
+ try:
297
+ os.fsync(descriptor)
298
+ finally:
299
+ os.close(descriptor)
300
+
301
+
302
+ resolve_artifact_reference = resolve_input_reference
303
+ verify_artifacts = verify_manifest_artifacts
@@ -0,0 +1,338 @@
1
+ """Dependency-free command-line boundary for Specromancy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from collections.abc import Iterable, Sequence
10
+ from pathlib import Path
11
+ from typing import Any, TextIO
12
+
13
+ from .contracts import RESERVED_COMMANDS
14
+ from .actions import render_action_packet
15
+ from ._cli.cli_commands import adapter_command_metadata, command_definitions
16
+ from ._config.config_models import PipelineConfig
17
+ from .engine import Engine
18
+ from .errors import SpecromancyError, UsageError
19
+ from .exit_codes import EXIT_CODE_DESCRIPTIONS, ExitCode
20
+ from ._cli.pipeline_selection import selected_pipeline as _selected_pipeline
21
+ from .registry import load_registry
22
+ from .status import render_status
23
+
24
+ ACTIONABLE_CODES = frozenset(
25
+ {
26
+ ExitCode.APPROVAL_REQUIRED,
27
+ ExitCode.AGENT_ACTION_REQUIRED,
28
+ ExitCode.RUN_BLOCKED,
29
+ ExitCode.RUN_PAUSED,
30
+ }
31
+ )
32
+
33
+
34
+ def discover_repository_root(
35
+ start: str | os.PathLike[str] | None = None,
36
+ explicit_root: str | os.PathLike[str] | None = None,
37
+ ) -> Path:
38
+ """Return an explicit root or walk upward until a Git marker is found.
39
+
40
+ A ``.git`` marker may be either a directory or a file, as used by Git
41
+ worktrees. Explicit roots intentionally do not need to be Git repositories,
42
+ which keeps fixture and non-Git use deterministic.
43
+ """
44
+
45
+ if explicit_root is not None:
46
+ root = Path(explicit_root).expanduser().resolve()
47
+ if not root.exists():
48
+ raise UsageError(
49
+ f"explicit repository root does not exist: {root}",
50
+ {"root": str(root)},
51
+ )
52
+ if not root.is_dir():
53
+ raise UsageError(
54
+ f"explicit repository root is not a directory: {root}",
55
+ {"root": str(root)},
56
+ )
57
+ return root
58
+
59
+ candidate = Path.cwd() if start is None else Path(start)
60
+ candidate = candidate.expanduser().resolve()
61
+ if candidate.is_file():
62
+ candidate = candidate.parent
63
+
64
+ for directory in (candidate, *candidate.parents):
65
+ if (directory / ".git").exists():
66
+ return directory
67
+
68
+ raise UsageError(
69
+ "could not find a repository root; pass --root PATH",
70
+ {"start": str(candidate)},
71
+ )
72
+
73
+
74
+ class CommandParser(argparse.ArgumentParser):
75
+ """Argument parser that reports expected failures through our contract."""
76
+
77
+ def error(self, message: str) -> None:
78
+ raise UsageError(message, {"usage": self.format_usage().strip()})
79
+
80
+
81
+ def _exit_code_help() -> str:
82
+ lines = ["exit codes:"]
83
+ for code, description in EXIT_CODE_DESCRIPTIONS.items():
84
+ lines.append(f" {int(code):>2} {description}")
85
+ return "\n".join(lines)
86
+
87
+
88
+ def _add_common_options(parser: argparse.ArgumentParser) -> None:
89
+ # SUPPRESS prevents a subparser's default from overwriting a value supplied
90
+ # before the command name on the root parser.
91
+ parser.add_argument(
92
+ "--root",
93
+ metavar="PATH",
94
+ default=argparse.SUPPRESS,
95
+ help="repository root (required when no .git marker can be discovered)",
96
+ )
97
+ parser.add_argument(
98
+ "--pipeline",
99
+ metavar="ID",
100
+ default=argparse.SUPPRESS,
101
+ help="registered pipeline ID (required for init)",
102
+ )
103
+ parser.add_argument(
104
+ "--json",
105
+ action="store_true",
106
+ default=argparse.SUPPRESS,
107
+ help="emit exactly one JSON object",
108
+ )
109
+ parser.add_argument(
110
+ "--quiet",
111
+ action="store_true",
112
+ default=argparse.SUPPRESS,
113
+ help="suppress human-readable non-error output",
114
+ )
115
+
116
+
117
+ def _add_command(
118
+ subparsers: Any,
119
+ name: str,
120
+ help_text: str,
121
+ arguments: Iterable[tuple[tuple[str, ...], dict[str, Any]]],
122
+ ) -> argparse.ArgumentParser:
123
+ command = subparsers.add_parser(name, help=help_text, description=help_text)
124
+ _add_common_options(command)
125
+ for names, options in arguments:
126
+ command.add_argument(*names, **options)
127
+ command.set_defaults(command=name)
128
+ return command
129
+
130
+
131
+ def build_parser(dynamic_phases: Iterable[str] = ()) -> CommandParser:
132
+ """Build the stable parser, optionally adding validated dynamic aliases."""
133
+
134
+ parser = CommandParser(
135
+ prog="specromancy",
136
+ description="Run a configured, artifact-based agentic pipeline.",
137
+ epilog=_exit_code_help(),
138
+ formatter_class=argparse.RawDescriptionHelpFormatter,
139
+ )
140
+ _add_common_options(parser)
141
+ subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
142
+
143
+ for definition in command_definitions(dynamic_phases):
144
+ command = _add_command(
145
+ subparsers, definition.name, definition.description, definition.arguments
146
+ )
147
+ if definition.name not in RESERVED_COMMANDS:
148
+ command.set_defaults(command="dynamic-phase", phase=definition.name)
149
+ elif definition.name == "adapters":
150
+ adapters = command
151
+
152
+ adapter_commands = adapters.add_subparsers(
153
+ dest="adapter_command", metavar="COMMAND"
154
+ )
155
+ generate = adapter_commands.add_parser(
156
+ "generate", help="generate harness adapters"
157
+ )
158
+ _add_common_options(generate)
159
+ generate.add_argument(
160
+ "--check",
161
+ action="store_true",
162
+ help="report adapter drift without writing files",
163
+ )
164
+ generate.set_defaults(command="adapters", adapter_command="generate")
165
+
166
+ return parser
167
+
168
+
169
+ def _write_payload(payload: dict[str, Any], stream: TextIO, as_json: bool) -> None:
170
+ if as_json:
171
+ stream.write(json.dumps(payload, sort_keys=True, separators=(",", ":")))
172
+ stream.write("\n")
173
+ return
174
+ if payload.get("kind") == "action":
175
+ stream.write(payload["message"])
176
+ stream.write("\n\n")
177
+ stream.write(render_action_packet(payload["action"]))
178
+ elif payload.get("kind") == "status":
179
+ stream.write(payload["message"])
180
+ stream.write("\n\n")
181
+ stream.write(render_status(payload["status"]))
182
+ elif payload.get("kind") == "approval":
183
+ stream.write(payload["message"])
184
+ approval = payload.get("approval")
185
+ if approval is not None:
186
+ stream.write(
187
+ f"\nReason: {approval['reason']}\nPhase: {approval['phase_id']}"
188
+ )
189
+ else:
190
+ stream.write(payload["message"])
191
+ stream.write("\n")
192
+
193
+
194
+ def _description(arguments: argparse.Namespace, root: Path) -> str:
195
+ inline = arguments.description
196
+ declared_file = getattr(arguments, "description_file", None)
197
+ if inline is not None and declared_file is not None:
198
+ raise UsageError("pass DESCRIPTION or --description-file, not both")
199
+ if declared_file is None:
200
+ if inline is None:
201
+ raise UsageError("DESCRIPTION or --description-file is required")
202
+ return inline
203
+ path = Path(declared_file).expanduser()
204
+ path = (root / path).resolve() if not path.is_absolute() else path.resolve()
205
+ try:
206
+ return path.read_text(encoding="utf-8")
207
+ except (OSError, UnicodeError) as exc:
208
+ raise UsageError(
209
+ f"cannot read description file: {path}", {"path": str(path)}
210
+ ) from exc
211
+
212
+
213
+ def _dispatch(
214
+ arguments: argparse.Namespace, root: Path, pipeline: PipelineConfig
215
+ ) -> dict[str, Any]:
216
+ engine = Engine(pipeline)
217
+ if arguments.command == "init":
218
+ return engine.initialize(_description(arguments, root))
219
+ if arguments.command in {"phase", "dynamic-phase"}:
220
+ return engine.start_phase(arguments.run_id, arguments.phase)
221
+ if arguments.command == "validate":
222
+ return engine.validate(
223
+ arguments.run_id, arguments.phase, outcome=arguments.outcome
224
+ )
225
+ if arguments.command == "request-approval":
226
+ return engine.request_approval(
227
+ arguments.run_id,
228
+ reason=arguments.reason,
229
+ details=arguments.details,
230
+ outcome=arguments.outcome,
231
+ )
232
+ if arguments.command == "approve":
233
+ return engine.approve(arguments.run_id, arguments.phase)
234
+ if arguments.command == "block":
235
+ return engine.block(
236
+ arguments.run_id, reason=arguments.reason, details=arguments.details
237
+ )
238
+ if arguments.command == "status":
239
+ return engine.status(arguments.run_id)
240
+ if arguments.command == "resume":
241
+ return engine.resume(arguments.run_id)
242
+ if arguments.command == "run":
243
+ return engine.run(arguments.run_id)
244
+ raise UsageError(f"unsupported command: {arguments.command}")
245
+
246
+
247
+ def _initial_arguments(raw_args: Sequence[str]) -> argparse.Namespace:
248
+ """Parse stable commands and provisionally parse a run-specific alias."""
249
+
250
+ probe = CommandParser(add_help=False)
251
+ _add_common_options(probe)
252
+ probe.add_argument("command", nargs="?")
253
+ probe.add_argument("remainder", nargs=argparse.REMAINDER)
254
+ command = probe.parse_args(raw_args)
255
+ if command.command and command.command not in RESERVED_COMMANDS:
256
+ if not command.remainder:
257
+ return build_parser().parse_args(raw_args)
258
+ return build_parser([command.command]).parse_args(raw_args)
259
+ return build_parser().parse_args(raw_args)
260
+
261
+
262
+ def main(
263
+ argv: Sequence[str] | None = None,
264
+ *,
265
+ stdout: TextIO | None = None,
266
+ stderr: TextIO | None = None,
267
+ ) -> int:
268
+ """Parse and dispatch a command, returning a stable process exit code."""
269
+
270
+ raw_args = list(sys.argv[1:] if argv is None else argv)
271
+ output = sys.stdout if stdout is None else stdout
272
+ errors = sys.stderr if stderr is None else stderr
273
+ wants_json = "--json" in raw_args
274
+ wants_quiet = "--quiet" in raw_args
275
+
276
+ # argparse renders help and raises SystemExit before returning a namespace.
277
+ # Handle JSON help at the process boundary so --json still keeps its
278
+ # one-object guarantee. Human-readable help retains argparse's normal form.
279
+ if wants_json and ("--help" in raw_args or "-h" in raw_args):
280
+ _write_payload(
281
+ {
282
+ "code": int(ExitCode.SUCCESS),
283
+ "message": build_parser().format_help(),
284
+ },
285
+ output,
286
+ True,
287
+ )
288
+ return int(ExitCode.SUCCESS)
289
+
290
+ try:
291
+ if any(arg in {"-h", "--help"} for arg in raw_args):
292
+ build_parser().parse_args(raw_args)
293
+ return int(ExitCode.SUCCESS)
294
+ arguments = _initial_arguments(raw_args)
295
+ if arguments.command is None:
296
+ raise UsageError("a command is required")
297
+ root = discover_repository_root(
298
+ explicit_root=getattr(arguments, "root", None)
299
+ )
300
+ registry = load_registry(root)
301
+ selector = getattr(arguments, "pipeline", None)
302
+ if arguments.command == "adapters" and not getattr(
303
+ arguments, "adapter_command", None
304
+ ):
305
+ raise UsageError("an adapters command is required")
306
+ if arguments.command == "adapters":
307
+ if selector is not None:
308
+ raise UsageError("adapters generate processes all registered pipelines; omit --pipeline")
309
+ from .adapters import generate_adapters
310
+
311
+ payload = generate_adapters(
312
+ root,
313
+ registry,
314
+ check=arguments.check,
315
+ )
316
+ else:
317
+ pipeline = _selected_pipeline(arguments, root, registry)
318
+ arguments = build_parser(pipeline.phase_ids).parse_args(raw_args)
319
+ payload = _dispatch(arguments, root, pipeline)
320
+ if wants_json or not wants_quiet:
321
+ _write_payload(payload, output, wants_json)
322
+ return int(payload["code"])
323
+ except SpecromancyError as exc:
324
+ payload = exc.as_dict()
325
+ # Approval, agent-action, and blocked statuses are actionable responses;
326
+ # all other expected failures are errors.
327
+ stream = output if exc.code in ACTIONABLE_CODES else errors
328
+ if wants_json or not (wants_quiet and exc.code in ACTIONABLE_CODES):
329
+ _write_payload(payload, stream, wants_json)
330
+ return int(exc.code)
331
+ except Exception as exc: # pragma: no cover - defensive process boundary
332
+ payload = {
333
+ "code": int(ExitCode.INTERNAL_ERROR),
334
+ "message": "internal Specromancy error",
335
+ "details": {"error": str(exc)},
336
+ }
337
+ _write_payload(payload, errors, wants_json)
338
+ return int(ExitCode.INTERNAL_ERROR)