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,497 @@
1
+ """Parse phases and their validation, command, transition, and artifact contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import string
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .config_fields import FieldContext, IDENTIFIER_PATTERN, _safe_declared_path
12
+ from .config_models import PhaseConfig, TransitionConfig, ValidationCommand, ValidatorConfig
13
+ from ..contracts import RESERVED_COMMANDS
14
+ from ..schema_validation import SchemaDefinitionError, load_json_schema
15
+
16
+
17
+ MUTATION_POLICIES = frozenset({"read-only", "repository-write", "allowlist"})
18
+ VALIDATOR_TYPES = frozenset({"markdown", "json", "file"})
19
+
20
+
21
+ def parse_phase(context: FieldContext, value: Any) -> PhaseConfig:
22
+ raw = context.require_mapping(value, "phases")
23
+ phase_id = context.require_identifier(raw.get("id"), "id")
24
+ allowed = {
25
+ "id",
26
+ "skill",
27
+ "inputs",
28
+ "output_name",
29
+ "output_template",
30
+ "mutation",
31
+ "allowlist",
32
+ "mutation_allowlist",
33
+ "completion_criteria",
34
+ "validator",
35
+ "validator_path",
36
+ "required_headings",
37
+ "heading_occurrence",
38
+ "commands",
39
+ "validation_commands",
40
+ "approval_conditions",
41
+ "stop_conditions",
42
+ "transitions",
43
+ "max_visits",
44
+ }
45
+ context.check_unknown(raw, allowed, phase=phase_id)
46
+ if phase_id in RESERVED_COMMANDS:
47
+ context.fail(
48
+ "reserved-phase-id",
49
+ f"phase id {phase_id!r} shadows a reserved CLI command",
50
+ phase=phase_id,
51
+ field="id",
52
+ value=phase_id,
53
+ remediation="choose a phase id that is not a CLI command",
54
+ )
55
+ skill = context.require_identifier(raw.get("skill"), "skill", phase=phase_id)
56
+ skill_path = context.existing_path(
57
+ f".agents/skills/{skill}/SKILL.md",
58
+ "skill",
59
+ phase=phase_id,
60
+ base=context.repository_root,
61
+ )
62
+ inputs = context.string_list(raw.get("inputs"), "inputs", phase=phase_id)
63
+ _validate_inputs(inputs, phase_id, context.fail)
64
+ output_name = context.require_string(
65
+ raw.get("output_name"), "output_name", phase=phase_id
66
+ )
67
+ _validate_output_pattern(
68
+ output_name, "output_name", context.fail, phase=phase_id
69
+ )
70
+ output_template_raw = raw.get("output_template")
71
+ output_template: str | None = None
72
+ output_template_path: Path | None = None
73
+ if output_template_raw is not None:
74
+ output_template = context.require_string(
75
+ output_template_raw, "output_template", phase=phase_id
76
+ )
77
+ output_template_path = context.existing_path(
78
+ output_template, "output_template", phase=phase_id
79
+ )
80
+ mutation = context.require_string(raw.get("mutation"), "mutation", phase=phase_id)
81
+ if mutation not in MUTATION_POLICIES:
82
+ context.fail(
83
+ "invalid-mutation-policy",
84
+ f"phase {phase_id!r} uses unsupported mutation policy {mutation!r}",
85
+ phase=phase_id,
86
+ field="mutation",
87
+ value=mutation,
88
+ remediation=f"choose one of {', '.join(sorted(MUTATION_POLICIES))}",
89
+ )
90
+ if "allowlist" in raw and "mutation_allowlist" in raw:
91
+ context.fail(
92
+ "conflicting-fields",
93
+ "allowlist and mutation_allowlist cannot both be declared",
94
+ phase=phase_id,
95
+ field="allowlist",
96
+ value=raw["allowlist"],
97
+ remediation="declare only allowlist",
98
+ )
99
+ allowlist_value = raw.get("allowlist", raw.get("mutation_allowlist", []))
100
+ allowlist = context.string_list(
101
+ allowlist_value, "allowlist", phase=phase_id, allow_empty=mutation != "allowlist"
102
+ )
103
+ for pattern in allowlist:
104
+ if not _safe_declared_path(pattern, allow_glob=True):
105
+ context.fail(
106
+ "unsafe-path",
107
+ f"allowlist pattern escapes the repository: {pattern!r}",
108
+ phase=phase_id,
109
+ field="allowlist",
110
+ value=pattern,
111
+ remediation="use repository-relative glob patterns without parent segments",
112
+ )
113
+ if mutation != "allowlist" and allowlist:
114
+ context.fail(
115
+ "unexpected-allowlist",
116
+ f"mutation policy {mutation!r} cannot declare an allowlist",
117
+ phase=phase_id,
118
+ field="allowlist",
119
+ value=allowlist,
120
+ remediation="remove allowlist or use mutation = 'allowlist'",
121
+ )
122
+ completion_criteria = context.string_list(
123
+ raw.get("completion_criteria"),
124
+ "completion_criteria",
125
+ phase=phase_id,
126
+ allow_empty=False,
127
+ )
128
+ validator = parse_validator(context, raw, phase_id)
129
+ commands = parse_commands(context, raw, phase_id)
130
+ approval_conditions = context.string_list(
131
+ raw.get("approval_conditions"),
132
+ "approval_conditions",
133
+ phase=phase_id,
134
+ identifiers=True,
135
+ )
136
+ stop_conditions = context.string_list(
137
+ raw.get("stop_conditions"),
138
+ "stop_conditions",
139
+ phase=phase_id,
140
+ identifiers=True,
141
+ )
142
+ transitions_raw = raw.get("transitions")
143
+ if not isinstance(transitions_raw, list):
144
+ context.fail(
145
+ "invalid-field-type",
146
+ "transitions must be an array of tables",
147
+ phase=phase_id,
148
+ field="transitions",
149
+ value=transitions_raw,
150
+ remediation="declare one or more [[phases.transitions]] tables",
151
+ )
152
+ transitions = tuple(
153
+ parse_transition(context, item, phase_id) for item in transitions_raw
154
+ )
155
+ max_visits = None
156
+ if "max_visits" in raw:
157
+ max_visits = context.require_integer(
158
+ raw["max_visits"], "max_visits", phase=phase_id, minimum=1
159
+ )
160
+ return PhaseConfig(
161
+ id=phase_id,
162
+ skill=skill,
163
+ skill_path=skill_path,
164
+ inputs=inputs,
165
+ output_name=output_name,
166
+ output_template=output_template,
167
+ output_template_path=output_template_path,
168
+ mutation=mutation,
169
+ allowlist=allowlist,
170
+ completion_criteria=completion_criteria,
171
+ validator=validator,
172
+ commands=commands,
173
+ approval_conditions=approval_conditions,
174
+ stop_conditions=stop_conditions,
175
+ transitions=transitions,
176
+ max_visits=max_visits,
177
+ )
178
+
179
+
180
+ def parse_validator(
181
+ context: FieldContext, phase_raw: Mapping[str, Any], phase_id: str
182
+ ) -> ValidatorConfig:
183
+ raw = phase_raw.get("validator")
184
+ required_headings_raw: Any = phase_raw.get("required_headings", [])
185
+ heading_occurrence_raw: Any = phase_raw.get(
186
+ "heading_occurrence", "exactly-once"
187
+ )
188
+ declared_path_raw: Any = phase_raw.get("validator_path")
189
+ if isinstance(raw, str):
190
+ validator_type = context.require_string(raw, "validator", phase=phase_id)
191
+ elif isinstance(raw, Mapping):
192
+ context.check_unknown(
193
+ raw,
194
+ {
195
+ "type",
196
+ "required_headings",
197
+ "heading_occurrence",
198
+ "path",
199
+ "schema",
200
+ },
201
+ phase=phase_id,
202
+ )
203
+ validator_type = context.require_string(
204
+ raw.get("type"), "validator.type", phase=phase_id
205
+ )
206
+ if "required_headings" in raw:
207
+ if "required_headings" in phase_raw:
208
+ context.fail(
209
+ "conflicting-fields",
210
+ "required_headings is declared twice",
211
+ phase=phase_id,
212
+ field="required_headings",
213
+ value=required_headings_raw,
214
+ remediation="declare headings either beside or inside validator",
215
+ )
216
+ required_headings_raw = raw["required_headings"]
217
+ if "heading_occurrence" in raw:
218
+ if "heading_occurrence" in phase_raw:
219
+ context.fail(
220
+ "conflicting-fields",
221
+ "heading_occurrence is declared twice",
222
+ phase=phase_id,
223
+ field="heading_occurrence",
224
+ value=heading_occurrence_raw,
225
+ remediation="declare it either beside or inside validator",
226
+ )
227
+ heading_occurrence_raw = raw["heading_occurrence"]
228
+ if "path" in raw and "schema" in raw:
229
+ context.fail(
230
+ "conflicting-fields",
231
+ "validator path and schema cannot both be declared",
232
+ phase=phase_id,
233
+ field="validator.path",
234
+ value=raw["path"],
235
+ remediation="declare one validator file path",
236
+ )
237
+ nested_path = raw.get("path", raw.get("schema"))
238
+ if nested_path is not None:
239
+ if declared_path_raw is not None:
240
+ context.fail(
241
+ "conflicting-fields",
242
+ "validator path is declared twice",
243
+ phase=phase_id,
244
+ field="validator_path",
245
+ value=declared_path_raw,
246
+ remediation="declare the path either beside or inside validator",
247
+ )
248
+ declared_path_raw = nested_path
249
+ else:
250
+ context.fail(
251
+ "invalid-field-type",
252
+ "validator must be a type string or table",
253
+ phase=phase_id,
254
+ field="validator",
255
+ value=raw,
256
+ remediation="use markdown, json, or file",
257
+ )
258
+ if validator_type not in VALIDATOR_TYPES:
259
+ context.fail(
260
+ "invalid-validator",
261
+ f"phase {phase_id!r} uses unsupported validator {validator_type!r}",
262
+ phase=phase_id,
263
+ field="validator",
264
+ value=validator_type,
265
+ remediation=f"choose one of {', '.join(sorted(VALIDATOR_TYPES))}",
266
+ )
267
+ required_headings = context.string_list(
268
+ required_headings_raw,
269
+ "required_headings",
270
+ phase=phase_id,
271
+ )
272
+ if validator_type != "markdown" and required_headings:
273
+ context.fail(
274
+ "invalid-validator-rule",
275
+ "required_headings is supported only by the markdown validator",
276
+ phase=phase_id,
277
+ field="required_headings",
278
+ value=required_headings,
279
+ remediation="remove the headings or use validator = 'markdown'",
280
+ )
281
+ heading_occurrence = context.require_string(
282
+ heading_occurrence_raw, "heading_occurrence", phase=phase_id
283
+ )
284
+ if heading_occurrence not in {"exactly-once", "at-least-once"}:
285
+ context.fail(
286
+ "invalid-heading-occurrence",
287
+ "heading_occurrence must be exactly-once or at-least-once",
288
+ phase=phase_id,
289
+ field="heading_occurrence",
290
+ value=heading_occurrence,
291
+ remediation="choose exactly-once or at-least-once",
292
+ )
293
+ if validator_type != "markdown" and heading_occurrence_raw != "exactly-once":
294
+ context.fail(
295
+ "invalid-validator-rule",
296
+ "heading_occurrence is supported only by the markdown validator",
297
+ phase=phase_id,
298
+ field="heading_occurrence",
299
+ value=heading_occurrence,
300
+ remediation="remove the setting or use validator = 'markdown'",
301
+ )
302
+ declared_path: str | None = None
303
+ resolved_path: Path | None = None
304
+ json_schema: Mapping[str, Any] | None = None
305
+ if declared_path_raw is not None:
306
+ declared_path = context.require_string(
307
+ declared_path_raw, "validator_path", phase=phase_id
308
+ )
309
+ resolved_path = context.existing_path(
310
+ declared_path, "validator_path", phase=phase_id
311
+ )
312
+ if validator_type != "json":
313
+ context.fail(
314
+ "invalid-validator-rule",
315
+ "a validator schema path is supported only by the JSON validator",
316
+ phase=phase_id,
317
+ field="validator_path",
318
+ value=declared_path,
319
+ remediation="remove the path or use validator = 'json'",
320
+ )
321
+ try:
322
+ json_schema = load_json_schema(resolved_path)
323
+ except SchemaDefinitionError as exc:
324
+ context.fail(
325
+ "unsupported-json-schema",
326
+ f"invalid JSON validator schema at {exc.location}: {exc}",
327
+ phase=phase_id,
328
+ field="validator_path",
329
+ value=declared_path,
330
+ remediation="use only type, required, properties, items, enum, pattern, and additionalProperties",
331
+ )
332
+ return ValidatorConfig(
333
+ type=validator_type,
334
+ required_headings=required_headings,
335
+ heading_occurrence=heading_occurrence,
336
+ declared_path=declared_path,
337
+ resolved_path=resolved_path,
338
+ json_schema=json_schema,
339
+ )
340
+
341
+
342
+ def parse_commands(
343
+ context: FieldContext, raw: Mapping[str, Any], phase_id: str
344
+ ) -> tuple[ValidationCommand, ...]:
345
+ if "commands" in raw and "validation_commands" in raw:
346
+ context.fail(
347
+ "conflicting-fields",
348
+ "commands and validation_commands cannot both be declared",
349
+ phase=phase_id,
350
+ field="commands",
351
+ value=raw["commands"],
352
+ remediation="declare only commands",
353
+ )
354
+ values = raw.get("commands", raw.get("validation_commands", []))
355
+ if not isinstance(values, list):
356
+ context.fail(
357
+ "invalid-field-type",
358
+ "commands must be an array of tables or argument arrays",
359
+ phase=phase_id,
360
+ field="commands",
361
+ value=values,
362
+ remediation="use command tables with an argv string array",
363
+ )
364
+ commands: list[ValidationCommand] = []
365
+ for value in values:
366
+ if isinstance(value, list):
367
+ command_raw: Mapping[str, Any] = {"argv": value}
368
+ else:
369
+ command_raw = context.require_mapping(value, "commands", phase=phase_id)
370
+ context.check_unknown(
371
+ command_raw, {"argv", "timeout_seconds", "required"}, phase=phase_id
372
+ )
373
+ argv = context.string_list(
374
+ command_raw.get("argv"), "commands.argv", phase=phase_id, allow_empty=False
375
+ )
376
+ timeout_raw = command_raw.get("timeout_seconds", 300)
377
+ if (
378
+ not isinstance(timeout_raw, (int, float))
379
+ or isinstance(timeout_raw, bool)
380
+ or not math.isfinite(timeout_raw)
381
+ or timeout_raw <= 0
382
+ ):
383
+ context.fail(
384
+ "invalid-command-timeout",
385
+ "command timeout_seconds must be positive",
386
+ phase=phase_id,
387
+ field="commands.timeout_seconds",
388
+ value=timeout_raw,
389
+ remediation="set a positive timeout in seconds",
390
+ )
391
+ required = command_raw.get("required", True)
392
+ if not isinstance(required, bool):
393
+ context.fail(
394
+ "invalid-field-type",
395
+ "commands.required must be a boolean",
396
+ phase=phase_id,
397
+ field="commands.required",
398
+ value=required,
399
+ remediation="use true or false",
400
+ )
401
+ commands.append(
402
+ ValidationCommand(argv, float(timeout_raw), required)
403
+ )
404
+ return tuple(commands)
405
+
406
+
407
+ def parse_transition(context: FieldContext, value: Any, phase_id: str) -> TransitionConfig:
408
+ raw = context.require_mapping(value, "transitions", phase=phase_id)
409
+ context.check_unknown(
410
+ raw, {"outcome", "target", "max_traversals", "pause"}, phase=phase_id
411
+ )
412
+ outcome = context.require_identifier(
413
+ raw.get("outcome"), "transitions.outcome", phase=phase_id
414
+ )
415
+ target = None
416
+ if "target" in raw:
417
+ target = context.require_identifier(
418
+ raw["target"], "transitions.target", phase=phase_id
419
+ )
420
+ max_traversals = None
421
+ if "max_traversals" in raw:
422
+ max_traversals = context.require_integer(
423
+ raw["max_traversals"],
424
+ "transitions.max_traversals",
425
+ phase=phase_id,
426
+ minimum=1,
427
+ )
428
+ pause = raw.get("pause", False)
429
+ if not isinstance(pause, bool):
430
+ context.fail(
431
+ "invalid-field-type",
432
+ "transitions.pause must be a boolean",
433
+ phase=phase_id,
434
+ field="transitions.pause",
435
+ value=pause,
436
+ remediation="use true or false",
437
+ )
438
+ return TransitionConfig(outcome, target, max_traversals, pause)
439
+
440
+
441
+ def _validate_output_pattern(
442
+ pattern: str,
443
+ field: str,
444
+ fail: Any,
445
+ *,
446
+ phase: str | None = None,
447
+ output_name: str = "output.md",
448
+ ) -> None:
449
+ allowed_fields = {"visit", "phase", "output_name"}
450
+ try:
451
+ parsed = tuple(string.Formatter().parse(pattern))
452
+ for _, name, _, conversion in parsed:
453
+ if name is not None and name not in allowed_fields:
454
+ raise ValueError(f"unknown placeholder {name!r}")
455
+ if conversion:
456
+ raise ValueError("conversions are not supported")
457
+ rendered = pattern.format(visit=1, phase=phase or "phase", output_name=output_name)
458
+ except (KeyError, IndexError, ValueError) as exc:
459
+ fail(
460
+ "invalid-output-pattern",
461
+ f"{field} is not a valid output pattern: {exc}",
462
+ phase=phase,
463
+ field=field,
464
+ value=pattern,
465
+ remediation="use only {visit}, {phase}, and {output_name} placeholders",
466
+ )
467
+ if not _safe_declared_path(rendered):
468
+ fail(
469
+ "unsafe-output-path",
470
+ f"{field} can resolve outside a run directory",
471
+ phase=phase,
472
+ field=field,
473
+ value=pattern,
474
+ remediation="use a relative output path without parent segments",
475
+ )
476
+
477
+
478
+ def _validate_inputs(inputs: tuple[str, ...], phase: str, fail: Any) -> None:
479
+ for reference in inputs:
480
+ if reference == "request":
481
+ continue
482
+ if reference.startswith("latest:"):
483
+ producer = reference.removeprefix("latest:")
484
+ if IDENTIFIER_PATTERN.fullmatch(producer):
485
+ continue
486
+ if reference.startswith("visit:"):
487
+ ordinal = reference.removeprefix("visit:")
488
+ if ordinal.isascii() and ordinal.isdigit() and int(ordinal) >= 1:
489
+ continue
490
+ fail(
491
+ "invalid-input-reference",
492
+ f"phase {phase!r} has invalid input reference {reference!r}",
493
+ phase=phase,
494
+ field="inputs",
495
+ value=reference,
496
+ remediation="use request, latest:<phase>, or visit:<positive-number>",
497
+ )
@@ -0,0 +1,76 @@
1
+ """Canonical pipeline documents and their compatibility-sensitive encoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .config_models import PhaseConfig
9
+
10
+
11
+ def _canonical_document(
12
+ schema_version: int,
13
+ pipeline_id: str,
14
+ version: int,
15
+ start: str,
16
+ terminal_outcomes: tuple[str, ...],
17
+ artifact_pattern: str,
18
+ allow_non_git: bool,
19
+ phases: tuple[PhaseConfig, ...],
20
+ ) -> dict[str, Any]:
21
+ return {
22
+ "allow_non_git": allow_non_git,
23
+ "artifact_pattern": artifact_pattern,
24
+ "id": pipeline_id,
25
+ "phases": [
26
+ {
27
+ "allowlist": list(phase.allowlist),
28
+ "approval_conditions": list(phase.approval_conditions),
29
+ "commands": [
30
+ {
31
+ "argv": list(command.argv),
32
+ "required": command.required,
33
+ "timeout_seconds": command.timeout_seconds,
34
+ }
35
+ for command in phase.commands
36
+ ],
37
+ "completion_criteria": list(phase.completion_criteria),
38
+ "id": phase.id,
39
+ "inputs": list(phase.inputs),
40
+ "max_visits": phase.max_visits,
41
+ "mutation": phase.mutation,
42
+ "output_name": phase.output_name,
43
+ "output_template": phase.output_template,
44
+ "skill": phase.skill,
45
+ "stop_conditions": list(phase.stop_conditions),
46
+ "transitions": [
47
+ {
48
+ "max_traversals": transition.max_traversals,
49
+ "outcome": transition.outcome,
50
+ "target": transition.target,
51
+ **({"pause": True} if transition.pause else {}),
52
+ }
53
+ for transition in sorted(
54
+ phase.transitions, key=lambda item: item.outcome
55
+ )
56
+ ],
57
+ "validator": {
58
+ "heading_occurrence": phase.validator.heading_occurrence,
59
+ "path": phase.validator.declared_path,
60
+ "required_headings": list(phase.validator.required_headings),
61
+ "type": phase.validator.type,
62
+ },
63
+ }
64
+ for phase in phases
65
+ ],
66
+ "schema_version": schema_version,
67
+ "start": start,
68
+ "terminal_outcomes": sorted(terminal_outcomes),
69
+ "version": version,
70
+ }
71
+
72
+
73
+ def _encode_canonical_document(document: dict[str, Any]) -> str:
74
+ """Preserve pipeline ASCII escaping, unlike generic run JSON hashing."""
75
+
76
+ return json.dumps(document, sort_keys=True, separators=(",", ":"))
@@ -0,0 +1 @@
1
+ """Private engine implementation components; use the public facades."""