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.
- specromancy_engine/__init__.py +32 -0
- specromancy_engine/__main__.py +8 -0
- specromancy_engine/_adapters/__init__.py +1 -0
- specromancy_engine/_adapters/adapter_contracts.py +81 -0
- specromancy_engine/_adapters/adapter_ownership.py +302 -0
- specromancy_engine/_adapters/adapter_rendering.py +218 -0
- specromancy_engine/_adapters/adapter_sources.py +140 -0
- specromancy_engine/_cli/__init__.py +1 -0
- specromancy_engine/_cli/cli_commands.py +136 -0
- specromancy_engine/_cli/pipeline_selection.py +28 -0
- specromancy_engine/_config/__init__.py +1 -0
- specromancy_engine/_config/config_errors.py +57 -0
- specromancy_engine/_config/config_fields.py +228 -0
- specromancy_engine/_config/config_loader.py +246 -0
- specromancy_engine/_config/config_models.py +97 -0
- specromancy_engine/_config/config_phase_parser.py +497 -0
- specromancy_engine/_config/config_serialization.py +76 -0
- specromancy_engine/_engine/__init__.py +1 -0
- specromancy_engine/_engine/approval_service.py +278 -0
- specromancy_engine/_engine/approvals.py +248 -0
- specromancy_engine/_engine/command_decisions.py +81 -0
- specromancy_engine/_engine/engine_errors.py +22 -0
- specromancy_engine/_engine/provenance.py +58 -0
- specromancy_engine/_engine/responses.py +129 -0
- specromancy_engine/_engine/validation_service.py +112 -0
- specromancy_engine/_runs/__init__.py +1 -0
- specromancy_engine/_runs/run_errors.py +51 -0
- specromancy_engine/_runs/run_identity.py +66 -0
- specromancy_engine/_runs/run_persistence.py +303 -0
- specromancy_engine/_runs/run_records.py +159 -0
- specromancy_engine/_runs/run_validation.py +241 -0
- specromancy_engine/_runs/visit_preparation.py +61 -0
- specromancy_engine/_runs/visit_transitions.py +311 -0
- specromancy_engine/_version.py +18 -0
- specromancy_engine/actions.py +118 -0
- specromancy_engine/adapters.py +97 -0
- specromancy_engine/artifacts.py +303 -0
- specromancy_engine/cli.py +338 -0
- specromancy_engine/commands.py +140 -0
- specromancy_engine/config.py +63 -0
- specromancy_engine/contracts.py +20 -0
- specromancy_engine/engine.py +423 -0
- specromancy_engine/errors.py +42 -0
- specromancy_engine/exit_codes.py +53 -0
- specromancy_engine/git.py +264 -0
- specromancy_engine/graph.py +321 -0
- specromancy_engine/hashing.py +95 -0
- specromancy_engine/locking.py +107 -0
- specromancy_engine/registry.py +134 -0
- specromancy_engine/run_store.py +643 -0
- specromancy_engine/schema_validation.py +178 -0
- specromancy_engine/schemas/event.schema.json +34 -0
- specromancy_engine/schemas/pipeline.schema.json +154 -0
- specromancy_engine/schemas/pipelines.schema.json +38 -0
- specromancy_engine/schemas/run.schema.json +152 -0
- specromancy_engine/status.py +106 -0
- specromancy_engine/validation.py +241 -0
- specromancy_engine-0.1.3.dist-info/METADATA +122 -0
- specromancy_engine-0.1.3.dist-info/RECORD +63 -0
- specromancy_engine-0.1.3.dist-info/WHEEL +5 -0
- specromancy_engine-0.1.3.dist-info/entry_points.txt +2 -0
- specromancy_engine-0.1.3.dist-info/licenses/LICENSE +21 -0
- 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)
|