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,140 @@
1
+ """Run trusted validation commands without a shell and capture bounded summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import time
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ._config.config_models import ValidationCommand
14
+
15
+
16
+ DEFAULT_SUMMARY_BYTES = 4096
17
+
18
+
19
+ def execute_validation_commands(
20
+ commands: tuple[ValidationCommand, ...] | list[ValidationCommand],
21
+ repository_root: Path,
22
+ run_directory: Path,
23
+ visit_number: int,
24
+ *,
25
+ summary_bytes: int = DEFAULT_SUMMARY_BYTES,
26
+ fault_injector: Callable[[str], None] | None = None,
27
+ ) -> list[dict[str, Any]]:
28
+ """Execute commands sequentially, stopping at the first required failure."""
29
+
30
+ results: list[dict[str, Any]] = []
31
+ secret_values = _secret_environment_values()
32
+ command_directory = run_directory / "commands"
33
+ command_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
34
+ for index, command in enumerate(commands, 1):
35
+ stdout_path = command_directory / f"{visit_number:03}-{index:02}-stdout.txt"
36
+ stderr_path = command_directory / f"{visit_number:03}-{index:02}-stderr.txt"
37
+ started = time.monotonic()
38
+ timed_out = False
39
+ missing = False
40
+ try:
41
+ completed = subprocess.run(
42
+ list(command.argv),
43
+ cwd=repository_root,
44
+ check=False,
45
+ shell=False,
46
+ capture_output=True,
47
+ timeout=command.timeout_seconds,
48
+ env=os.environ.copy(),
49
+ )
50
+ stdout = completed.stdout
51
+ stderr = completed.stderr
52
+ exit_code: int | None = completed.returncode
53
+ except subprocess.TimeoutExpired as exc:
54
+ timed_out = True
55
+ stdout = _as_bytes(exc.stdout)
56
+ stderr = _as_bytes(exc.stderr)
57
+ exit_code = None
58
+ except OSError as exc:
59
+ missing = isinstance(exc, FileNotFoundError)
60
+ stdout = b""
61
+ stderr = str(exc).encode("utf-8", "replace")
62
+ exit_code = None
63
+ duration = max(0.0, time.monotonic() - started)
64
+ if fault_injector is not None:
65
+ fault_injector("before-command-output-write")
66
+ stdout_path.write_bytes(stdout)
67
+ if fault_injector is not None:
68
+ fault_injector("after-command-stdout-write")
69
+ stderr_path.write_bytes(stderr)
70
+ if fault_injector is not None:
71
+ fault_injector("after-command-output-write")
72
+ failed = timed_out or missing or exit_code != 0
73
+ result = {
74
+ "executable": command.argv[0],
75
+ "arguments": list(command.argv[1:]),
76
+ "required": command.required,
77
+ "exit_code": exit_code,
78
+ "duration_seconds": round(duration, 6),
79
+ "timed_out": timed_out,
80
+ "missing_executable": missing,
81
+ "status": "failed" if failed else "passed",
82
+ "stdout_path": stdout_path.relative_to(run_directory).as_posix(),
83
+ "stderr_path": stderr_path.relative_to(run_directory).as_posix(),
84
+ "stdout_summary": _summary(stdout, summary_bytes, secret_values),
85
+ "stderr_summary": _summary(stderr, summary_bytes, secret_values),
86
+ }
87
+ results.append(result)
88
+ if failed and command.required:
89
+ break
90
+ return results
91
+
92
+
93
+ def first_required_failure(results: list[dict[str, Any]]) -> dict[str, Any] | None:
94
+ return next(
95
+ (
96
+ result
97
+ for result in results
98
+ if result.get("required") and result.get("status") == "failed"
99
+ ),
100
+ None,
101
+ )
102
+
103
+
104
+ def _summary(content: bytes, limit: int, secret_values: tuple[str, ...] = ()) -> str:
105
+ if limit < 0:
106
+ raise ValueError("summary byte limit must not be negative")
107
+ text = content.decode("utf-8", "replace")
108
+ for value in secret_values:
109
+ text = text.replace(value, "[REDACTED]")
110
+ redacted = text.encode("utf-8")
111
+ prefix = redacted[:limit].decode("utf-8", "replace")
112
+ if len(redacted) > limit:
113
+ prefix += f"\n...[truncated {len(redacted) - limit} bytes]"
114
+ return prefix
115
+
116
+
117
+ def _secret_environment_values() -> tuple[str, ...]:
118
+ sensitive = re.compile(
119
+ r"(?:SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|API[_-]?KEY|AUTH)",
120
+ re.IGNORECASE,
121
+ )
122
+ return tuple(
123
+ sorted(
124
+ {
125
+ value
126
+ for name, value in os.environ.items()
127
+ if value and sensitive.search(name)
128
+ },
129
+ key=len,
130
+ reverse=True,
131
+ )
132
+ )
133
+
134
+
135
+ def _as_bytes(value: bytes | str | None) -> bytes:
136
+ if value is None:
137
+ return b""
138
+ if isinstance(value, bytes):
139
+ return value
140
+ return value.encode("utf-8", "replace")
@@ -0,0 +1,63 @@
1
+ """Public compatibility facade for declarative pipeline configuration.
2
+
3
+ Parsing, immutable records, diagnostics, and canonical encoding live in separate
4
+ internal modules. Existing imports here continue to reference those same objects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from ._config.config_errors import PipelineConfigError, _MISSING, _json_value
12
+ from ._config.config_loader import (
13
+ IDENTIFIER_PATTERN,
14
+ MUTATION_POLICIES,
15
+ SUPPORTED_SCHEMA_VERSION,
16
+ VALIDATOR_TYPES,
17
+ _Loader,
18
+ _discover_root,
19
+ _safe_declared_path,
20
+ _validate_inputs,
21
+ _validate_output_pattern,
22
+ load_pipeline,
23
+ )
24
+ from ._config.config_models import (
25
+ PhaseConfig,
26
+ PipelineConfig,
27
+ TransitionConfig,
28
+ ValidationCommand,
29
+ ValidatorConfig,
30
+ )
31
+ from ._config.config_serialization import _canonical_document
32
+ from .contracts import RESERVED_COMMANDS
33
+
34
+
35
+ def load_config(
36
+ path: str | Path,
37
+ repository_root: str | Path | None = None,
38
+ ) -> PipelineConfig:
39
+ """Backward-compatible descriptive alias for :func:`load_pipeline`."""
40
+
41
+ return load_pipeline(path, repository_root)
42
+
43
+
44
+ def canonicalize_pipeline(pipeline: PipelineConfig) -> str:
45
+ return pipeline.canonical_json
46
+
47
+
48
+ def hash_pipeline(pipeline: PipelineConfig) -> str:
49
+ return pipeline.config_hash
50
+
51
+
52
+ def require_pipeline_hash(pipeline: PipelineConfig, expected_hash: str) -> None:
53
+ """Reject configuration drift for a persisted in-progress run."""
54
+
55
+ if pipeline.config_hash != expected_hash:
56
+ raise PipelineConfigError(
57
+ "pipeline-hash-mismatch",
58
+ "pipeline configuration changed after the run was created",
59
+ path=pipeline.path,
60
+ field="config_hash",
61
+ value={"expected": expected_hash, "actual": pipeline.config_hash},
62
+ remediation="restore the original pipeline or start a new run",
63
+ )
@@ -0,0 +1,20 @@
1
+ """Small public constants shared across configuration and CLI boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ RESERVED_COMMANDS = frozenset(
7
+ {
8
+ "init",
9
+ "phase",
10
+ "validate",
11
+ "approve",
12
+ "request-approval",
13
+ "block",
14
+ "status",
15
+ "resume",
16
+ "run",
17
+ "adapters",
18
+ }
19
+ )
20
+
@@ -0,0 +1,423 @@
1
+ """Generic, deterministic state-machine execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ._engine import responses
10
+ from ._engine.approval_service import ApprovalService
11
+ from ._engine.approvals import approved_record, pending_approval
12
+ from ._engine.command_decisions import (
13
+ declared_reason as _declared_reason, illegal_transition, select_outcome,
14
+ )
15
+ from .artifacts import ArtifactError, verify_manifest_artifacts
16
+ from ._config.config_models import PhaseConfig, PipelineConfig
17
+ from ._engine.engine_errors import EngineError
18
+ from .errors import UsageError
19
+ from .exit_codes import ExitCode
20
+ from .git import GitError, capture_repository_snapshot
21
+ from ._engine.provenance import observe_provenance, pipeline_identity, pipeline_matches
22
+ from ._engine.responses import RESPONSE_SCHEMA_VERSION # Backward-compatible re-export.
23
+ from .run_store import RunStore, format_timestamp, utc_now
24
+ from ._runs.run_records import (
25
+ ApprovalRecord, RunRecord, VisitRecord, current_visit,
26
+ )
27
+ from ._engine.validation_service import ValidationResult, perform_validation
28
+
29
+
30
+ class Engine:
31
+ """Advance runs using only configured outcomes and persisted state."""
32
+
33
+ def __init__(
34
+ self,
35
+ pipeline: PipelineConfig,
36
+ store: RunStore | None = None,
37
+ *,
38
+ command_fault_injector: Callable[[str], None] | None = None,
39
+ ) -> None:
40
+ self.pipeline = pipeline
41
+ self.store = store or RunStore(pipeline.repository_root)
42
+ self._command_fault_injector = command_fault_injector
43
+
44
+ def initialize(self, description: str) -> dict[str, Any]:
45
+ if not description.strip():
46
+ raise UsageError("DESCRIPTION must not be empty")
47
+ try:
48
+ git = capture_repository_snapshot(
49
+ self.pipeline.repository_root,
50
+ allow_non_git=self.pipeline.allow_non_git,
51
+ )
52
+ except GitError as exc:
53
+ raise EngineError(
54
+ ExitCode.INVALID_PIPELINE,
55
+ exc.message,
56
+ exc.diagnostic_code,
57
+ **exc.details,
58
+ ) from exc
59
+ manifest = self.store.create(
60
+ self.pipeline,
61
+ description,
62
+ git_base=git,
63
+ git_head=git,
64
+ )
65
+ visit = self.store.prepare_visit(
66
+ manifest["run_id"], self.pipeline, self.pipeline.start
67
+ )
68
+ manifest = self.store.load(manifest["run_id"])
69
+ return responses.action_response(
70
+ self.pipeline,
71
+ manifest,
72
+ visit,
73
+ f"run {manifest['run_id']} initialized; agent action is required",
74
+ )
75
+
76
+ def start_phase(self, run_id: str, phase_id: str) -> dict[str, Any]:
77
+ manifest = self._load(run_id)
78
+ if manifest["status"] == "paused":
79
+ return responses.paused_response(
80
+ self.pipeline, manifest, "run is paused; resume it first"
81
+ )
82
+ visit = self._current_visit(manifest)
83
+ if visit is None or visit["phase_id"] != phase_id:
84
+ expected = visit["phase_id"] if visit is not None else None
85
+ raise EngineError(
86
+ ExitCode.ILLEGAL_TRANSITION,
87
+ f"cannot start phase {phase_id!r}; expected {expected!r}",
88
+ "unexpected-phase",
89
+ requested=phase_id,
90
+ expected=expected,
91
+ status=manifest["status"],
92
+ )
93
+ if visit["status"] == "pending":
94
+ try:
95
+ baseline = capture_repository_snapshot(
96
+ self.pipeline.repository_root,
97
+ allow_non_git=self.pipeline.allow_non_git,
98
+ )
99
+ except GitError as exc:
100
+ raise EngineError(
101
+ ExitCode.VALIDATION_FAILED,
102
+ exc.message,
103
+ exc.diagnostic_code,
104
+ **exc.details,
105
+ ) from exc
106
+ visit = self.store.activate_visit(
107
+ run_id,
108
+ self.pipeline,
109
+ visit["ordinal"],
110
+ mutation_baseline=baseline,
111
+ )
112
+ manifest = self.store.load(run_id)
113
+ elif visit["status"] != "active":
114
+ raise EngineError(
115
+ ExitCode.ILLEGAL_TRANSITION,
116
+ f"phase {phase_id!r} cannot start from {visit['status']}",
117
+ "illegal-visit-status",
118
+ phase=phase_id,
119
+ status=visit["status"],
120
+ )
121
+ return responses.action_response(
122
+ self.pipeline,
123
+ manifest, visit, f"phase {phase_id} is active; agent action is required"
124
+ )
125
+
126
+ def validate(
127
+ self,
128
+ run_id: str,
129
+ phase_id: str | None = None,
130
+ *,
131
+ outcome: str | None = None,
132
+ ) -> dict[str, Any]:
133
+ manifest = self._load(run_id)
134
+ if manifest["status"] == "completed":
135
+ return responses.status_response(
136
+ self.pipeline, manifest, "run is already completed"
137
+ )
138
+ if manifest["status"] == "paused":
139
+ return responses.paused_response(
140
+ self.pipeline, manifest, "run is paused; resume it first"
141
+ )
142
+ visit = self._current_visit(manifest)
143
+ if visit is None:
144
+ raise self._illegal("run has no current visit", manifest)
145
+ if visit["status"] == "pending":
146
+ previous = manifest["visits"][-2] if len(manifest["visits"]) > 1 else None
147
+ if previous is not None and previous["status"] == "completed" and (
148
+ phase_id is None or phase_id == previous["phase_id"]
149
+ ):
150
+ return responses.action_response(
151
+ self.pipeline,
152
+ manifest,
153
+ visit,
154
+ "validation was already recorded; the next phase requires agent action",
155
+ )
156
+ raise self._illegal(
157
+ f"phase {visit['phase_id']!r} must be started before validation",
158
+ manifest,
159
+ expected_phase=visit["phase_id"],
160
+ )
161
+ if visit["status"] != "active":
162
+ raise self._illegal(
163
+ f"visit cannot be validated from {visit['status']}", manifest
164
+ )
165
+ self._require_phase(visit, phase_id)
166
+ phase = self.pipeline.phase(visit["phase_id"])
167
+ selected = self._select_outcome(phase, outcome)
168
+ transition = next(item for item in phase.transitions if item.outcome == selected)
169
+ evidence = self._perform_validation(manifest, visit, phase)
170
+ updated = self.store.transition_visit(
171
+ run_id,
172
+ self.pipeline,
173
+ visit["ordinal"],
174
+ outcome=selected,
175
+ transition_target=transition.target,
176
+ terminal_result={
177
+ "outcome": selected,
178
+ "phase": phase.id,
179
+ "visit_number": visit["ordinal"],
180
+ },
181
+ validation_checks=evidence.checks,
182
+ mutation_result=evidence.mutation_result,
183
+ command_results=evidence.command_results,
184
+ )
185
+ return responses.after_transition(self.pipeline, updated, selected)
186
+
187
+ def request_approval(
188
+ self,
189
+ run_id: str,
190
+ *,
191
+ reason: str | None,
192
+ details: str | None = None,
193
+ outcome: str | None = None,
194
+ ) -> dict[str, Any]:
195
+ manifest = self._load(run_id)
196
+ return self._approval_service().request_approval(
197
+ manifest, reason=reason, details=details, outcome=outcome
198
+ )
199
+
200
+ def approve(self, run_id: str, phase_id: str) -> dict[str, Any]:
201
+ return self._approval_service().approve(run_id, phase_id)
202
+
203
+ def block(
204
+ self,
205
+ run_id: str,
206
+ *,
207
+ reason: str | None,
208
+ details: str | None = None,
209
+ ) -> dict[str, Any]:
210
+ manifest = self._load(run_id)
211
+ if manifest["status"] == "blocked":
212
+ recorded = manifest["block_reason"] or {}
213
+ if reason is not None and reason != recorded.get("reason"):
214
+ raise self._illegal(
215
+ "run is already blocked for a different reason", manifest
216
+ )
217
+ if details is not None and details != recorded.get("details"):
218
+ raise self._illegal(
219
+ "run is already blocked with different details", manifest
220
+ )
221
+ return responses.blocked_response(
222
+ self.pipeline, manifest, "run is already blocked"
223
+ )
224
+ visit = self._current_visit(manifest)
225
+ if visit is None or visit["status"] != "active":
226
+ raise self._illegal("only an active visit can be blocked", manifest)
227
+ phase = self.pipeline.phase(visit["phase_id"])
228
+ chosen_reason = _declared_reason(reason, phase.stop_conditions, "stop")
229
+ block = {
230
+ "phase_id": phase.id,
231
+ "visit_number": visit["ordinal"],
232
+ "reason": chosen_reason,
233
+ "details": details,
234
+ "recorded_at": format_timestamp(utc_now()),
235
+ }
236
+
237
+ def change(value: RunRecord) -> None:
238
+ current = self._current_visit(value)
239
+ assert current is not None
240
+ current["status"] = "blocked"
241
+ value["status"] = "blocked"
242
+ value["block_reason"] = block
243
+
244
+ manifest = self.store.mutate(
245
+ run_id,
246
+ "run-blocked",
247
+ change,
248
+ visit_number=visit["ordinal"],
249
+ payload={"reason": chosen_reason, "details": details},
250
+ )
251
+ return responses.blocked_response(self.pipeline, manifest, "run is blocked")
252
+
253
+ def status(self, run_id: str) -> dict[str, Any]:
254
+ manifest = self.store.load(run_id, verify_artifacts=False)
255
+ warnings: list[dict[str, Any]] = []
256
+ if not pipeline_matches(self.pipeline, manifest):
257
+ warnings.append(
258
+ {
259
+ "code": "pipeline-drift",
260
+ "message": "pipeline configuration differs from the run provenance",
261
+ }
262
+ )
263
+ try:
264
+ verify_manifest_artifacts(manifest, self.store.run_directory(run_id))
265
+ except ArtifactError as exc:
266
+ warnings.append(
267
+ {
268
+ "code": exc.diagnostic_code,
269
+ "message": exc.message,
270
+ "details": exc.details,
271
+ }
272
+ )
273
+ warnings.extend(observe_provenance(self.pipeline, manifest))
274
+ return responses.status_response(
275
+ self.pipeline, manifest, "run status", warnings=warnings
276
+ )
277
+
278
+ def resume(self, run_id: str) -> dict[str, Any]:
279
+ manifest = self._load(run_id)
280
+ if manifest["status"] == "paused":
281
+ manifest = self.store.resume_paused(run_id, self.pipeline)
282
+ return responses.response_for_state(self.pipeline, manifest, "run resumed")
283
+
284
+ def run(self, run_id: str) -> dict[str, Any]:
285
+ manifest = self._load(run_id)
286
+ visit = self._current_visit(manifest)
287
+ if manifest["status"] == "awaiting-approval" and visit is not None:
288
+ approved = approved_record(manifest, visit["ordinal"])
289
+ if approved is not None:
290
+ return self._advance_approved(manifest, visit, approved)
291
+ if visit is not None and visit["status"] == "pending":
292
+ return self.start_phase(run_id, visit["phase_id"])
293
+ return responses.response_for_state(
294
+ self.pipeline, manifest, "run reached a boundary"
295
+ )
296
+
297
+ def _approval_service(self) -> ApprovalService:
298
+ # Resolve validation and the engine clock at their original call sites,
299
+ # including hooks replaced after a grant has committed.
300
+ return ApprovalService(
301
+ self.pipeline,
302
+ self.store,
303
+ validate=lambda manifest, visit, phase: self._perform_validation(
304
+ manifest, visit, phase
305
+ ),
306
+ timestamp=lambda: format_timestamp(utc_now()),
307
+ )
308
+
309
+ def _advance_approved(
310
+ self,
311
+ manifest: RunRecord,
312
+ visit: VisitRecord,
313
+ approval: ApprovalRecord,
314
+ ) -> dict[str, Any]:
315
+ return self._approval_service().advance_approved(manifest, visit, approval)
316
+
317
+ def _load(self, run_id: str) -> RunRecord:
318
+ manifest = self.store.load(run_id)
319
+ if not pipeline_matches(self.pipeline, manifest):
320
+ raise EngineError(
321
+ ExitCode.INVALID_PIPELINE,
322
+ "pipeline configuration changed after the run was created",
323
+ "pipeline-hash-mismatch",
324
+ expected=manifest["pipeline"],
325
+ actual=pipeline_identity(self.pipeline),
326
+ )
327
+ provenance_warnings = observe_provenance(self.pipeline, manifest)
328
+ if provenance_warnings:
329
+ warning = provenance_warnings[0]
330
+ raise EngineError(
331
+ ExitCode.INTERNAL_ERROR,
332
+ warning["message"],
333
+ warning["code"],
334
+ **warning.get("details", {}),
335
+ )
336
+ return manifest
337
+
338
+ def _perform_validation(
339
+ self,
340
+ manifest: RunRecord,
341
+ visit: VisitRecord,
342
+ phase: PhaseConfig,
343
+ ) -> ValidationResult:
344
+ # Keep this orchestration seam for overlapping-attempt fault injection.
345
+ result = perform_validation(
346
+ self.pipeline,
347
+ self.store.run_directory(manifest["run_id"]),
348
+ visit,
349
+ phase,
350
+ command_fault_injector=self._command_fault_injector,
351
+ )
352
+ if result.failure is not None:
353
+ self.store.record_validation_attempt(
354
+ manifest["run_id"],
355
+ visit["ordinal"],
356
+ validation_checks=result.checks,
357
+ command_results=result.command_results,
358
+ mutation_result=result.mutation_result,
359
+ diagnostic={"error_code": result.failure.diagnostic_code},
360
+ )
361
+ raise result.failure from result.cause
362
+ return result
363
+
364
+ def _artifact_hash(
365
+ self, manifest: RunRecord, visit: VisitRecord
366
+ ) -> str:
367
+ return self._approval_service().artifact_hash(manifest, visit)
368
+
369
+ def _invalidate_approval(
370
+ self,
371
+ manifest: RunRecord,
372
+ visit: VisitRecord,
373
+ approval: ApprovalRecord,
374
+ mismatches: list[str],
375
+ ) -> None:
376
+ self._approval_service().invalidate_approval(manifest, visit, approval, mismatches)
377
+
378
+ def _select_outcome(self, phase: PhaseConfig, requested: str | None) -> str:
379
+ return select_outcome(phase, requested)
380
+
381
+ @staticmethod
382
+ def _current_visit(manifest: RunRecord) -> VisitRecord | None:
383
+ return current_visit(manifest)
384
+
385
+ # Preserve selector entry points used by existing callers and tests.
386
+ _pending_approval = staticmethod(pending_approval)
387
+ _approved_record = staticmethod(approved_record)
388
+
389
+ @staticmethod
390
+ def _require_phase(visit: VisitRecord, requested: str | None) -> None:
391
+ if requested is not None and requested != visit["phase_id"]:
392
+ raise EngineError(
393
+ ExitCode.ILLEGAL_TRANSITION,
394
+ f"requested phase {requested!r}; expected {visit['phase_id']!r}",
395
+ "unexpected-phase",
396
+ requested=requested,
397
+ expected=visit["phase_id"],
398
+ )
399
+
400
+ @staticmethod
401
+ def _illegal(
402
+ message: str, manifest: RunRecord, **details: Any
403
+ ) -> EngineError:
404
+ return illegal_transition(message, manifest, **details)
405
+
406
+ def _idempotent_approval_result(
407
+ self, manifest: RunRecord, phase_id: str
408
+ ) -> dict[str, Any]:
409
+ return self._approval_service().idempotent_approval_result(manifest, phase_id)
410
+
411
+
412
+ def capture_git_metadata(root: Path, *, include_status: bool = False) -> dict[str, Any]:
413
+ """Backward-compatible alias for content-level repository capture."""
414
+
415
+ snapshot = capture_repository_snapshot(root, allow_non_git=True)
416
+ if include_status:
417
+ return {**snapshot, "status": []}
418
+ return {
419
+ "is_worktree": snapshot["is_worktree"],
420
+ "head": snapshot["head"],
421
+ "branch": snapshot["branch"],
422
+ "status": [],
423
+ }
@@ -0,0 +1,42 @@
1
+ """Expected, user-facing failures raised by the Specromancy engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from .exit_codes import ExitCode
9
+
10
+
11
+ class SpecromancyError(Exception):
12
+ """A failure with a stable exit code and serializable diagnostics."""
13
+
14
+ def __init__(
15
+ self,
16
+ code: ExitCode | int,
17
+ message: str,
18
+ details: Mapping[str, Any] | None = None,
19
+ ) -> None:
20
+ super().__init__(message)
21
+ self.code = ExitCode(code)
22
+ self.message = message
23
+ self.details = dict(details) if details is not None else None
24
+
25
+ def as_dict(self) -> dict[str, Any]:
26
+ payload: dict[str, Any] = {
27
+ "code": int(self.code),
28
+ "message": self.message,
29
+ }
30
+ if self.details is not None:
31
+ payload["details"] = self.details
32
+ return payload
33
+
34
+
35
+ class UsageError(SpecromancyError):
36
+ """Invalid command-line input."""
37
+
38
+ def __init__(
39
+ self, message: str, details: Mapping[str, Any] | None = None
40
+ ) -> None:
41
+ super().__init__(ExitCode.USAGE_ERROR, message, details)
42
+