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,311 @@
1
+ """Pure visit decisions over validated records and explicitly collected evidence.
2
+
3
+ No clocks, artifact reads, locks, revisions, or durable writes belong here.
4
+ These internal helpers preserve the distinct RunStore operation contracts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from .._config.config_models import PipelineConfig
14
+ from ..exit_codes import ExitCode
15
+ from .run_errors import RunCorruptionError, RunStoreError
16
+ from .run_records import (
17
+ InputArtifactRecord, RunRecord, SealedArtifactRecord, VisitRecord, visit_by_number,
18
+ )
19
+
20
+
21
+ def transition_limit_block(
22
+ manifest: RunRecord,
23
+ pipeline: PipelineConfig,
24
+ visit: VisitRecord,
25
+ outcome: str,
26
+ transition_target: str | None,
27
+ *,
28
+ timestamp: str,
29
+ ) -> dict[str, Any] | None:
30
+ """Return a persisted block record before a disallowed loop traversal."""
31
+
32
+ phase = pipeline.phase(visit["phase_id"])
33
+ transition = next(
34
+ item for item in phase.transitions if item.outcome == outcome
35
+ )
36
+ if transition.max_traversals is not None:
37
+ traversals = sum(
38
+ item["phase_id"] == phase.id
39
+ and item.get("chosen_outcome") == outcome
40
+ for item in manifest["visits"]
41
+ )
42
+ if traversals >= transition.max_traversals:
43
+ return {
44
+ "reason": "transition-traversal-limit",
45
+ "phase_id": phase.id,
46
+ "visit_number": visit["ordinal"],
47
+ "outcome": outcome,
48
+ "target": transition_target,
49
+ "limit": transition.max_traversals,
50
+ "recorded_at": timestamp,
51
+ "required_action": "new-run",
52
+ "remediation": "start a new run; this pipeline declares no counter-reset approval",
53
+ }
54
+ if transition_target is not None:
55
+ target_phase = pipeline.phase(transition_target)
56
+ if target_phase.max_visits is not None:
57
+ visits = sum(
58
+ item["phase_id"] == transition_target
59
+ for item in manifest["visits"]
60
+ )
61
+ if visits >= target_phase.max_visits:
62
+ return {
63
+ "reason": "phase-visit-limit",
64
+ "phase_id": transition_target,
65
+ "source_phase": phase.id,
66
+ "visit_number": visit["ordinal"],
67
+ "outcome": outcome,
68
+ "limit": target_phase.max_visits,
69
+ "recorded_at": timestamp,
70
+ "required_action": "new-run",
71
+ "remediation": "start a new run; this pipeline declares no counter-reset approval",
72
+ }
73
+ return None
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Decision:
78
+ """A detached proposed record and event, or an explicit unchanged result."""
79
+
80
+ manifest: RunRecord
81
+ event_type: str | None = None
82
+ visit_number: int | None = None
83
+ payload: dict[str, Any] = field(default_factory=dict)
84
+
85
+
86
+ def new_visit(
87
+ *,
88
+ phase_id: str,
89
+ ordinal: int,
90
+ attempt: int,
91
+ status: str,
92
+ inputs: list[InputArtifactRecord],
93
+ output_path: str,
94
+ mutation_policy: str,
95
+ mutation_baseline: Any,
96
+ skill: SealedArtifactRecord,
97
+ template: SealedArtifactRecord | None,
98
+ started_at: str | None,
99
+ deviations: list[Any] | None = None,
100
+ ) -> VisitRecord:
101
+ """Construct a visit from literal evidence collected at preparation time."""
102
+
103
+ if status not in {"pending", "active"}:
104
+ raise ValueError(f"invalid initial visit status: {status!r}")
105
+ return copy.deepcopy({
106
+ "phase_id": phase_id,
107
+ "ordinal": ordinal,
108
+ "attempt": attempt,
109
+ "status": status,
110
+ "inputs": inputs,
111
+ "output": {"path": output_path, "sha256": None},
112
+ "mutation_policy": mutation_policy,
113
+ "mutation_baseline": mutation_baseline,
114
+ "mutation_result": None,
115
+ "validation_checks": [],
116
+ "command_results": [],
117
+ "chosen_outcome": None,
118
+ "transition_target": None,
119
+ "skill": skill,
120
+ "template": template,
121
+ "started_at": started_at,
122
+ "completed_at": None,
123
+ "deviations": list(deviations or []),
124
+ })
125
+
126
+
127
+ def append_visit(manifest: RunRecord, visit: VisitRecord) -> Decision:
128
+ updated = copy.deepcopy(manifest)
129
+ updated["status"] = "active" if visit["status"] == "active" else "awaiting-agent"
130
+ updated["current_visit"] = visit["ordinal"]
131
+ updated["visits"].append(copy.deepcopy(visit))
132
+ return Decision(
133
+ updated,
134
+ "visit-started" if visit["status"] == "active" else "visit-prepared",
135
+ visit["ordinal"],
136
+ {"phase_id": visit["phase_id"], "attempt": visit["attempt"]},
137
+ )
138
+
139
+
140
+ def activation_needed(manifest: RunRecord, visit_number: int) -> bool:
141
+ """Preflight before the store observes the activation clock."""
142
+
143
+ if manifest["status"] == "paused":
144
+ raise RunStoreError(
145
+ "paused run must be resumed before its next visit can start",
146
+ diagnostic_code="run-paused",
147
+ details={"run_id": manifest["run_id"], "visit_number": visit_number},
148
+ code=ExitCode.RUN_PAUSED,
149
+ )
150
+ existing = visit_by_number(manifest, visit_number)
151
+ if existing["status"] == "active":
152
+ return False
153
+ if existing["status"] != "pending":
154
+ raise RunStoreError(
155
+ f"visit {visit_number} cannot be activated from {existing['status']}",
156
+ diagnostic_code="illegal-visit-status",
157
+ details={"visit_number": visit_number, "status": existing["status"],
158
+ "expected": "pending"},
159
+ code=ExitCode.ILLEGAL_TRANSITION,
160
+ )
161
+ return True
162
+
163
+
164
+ def activate_visit(
165
+ manifest: RunRecord, visit_number: int, *,
166
+ mutation_baseline: Any, started_at: str | None,
167
+ ) -> Decision:
168
+ updated = copy.deepcopy(manifest)
169
+ if not activation_needed(manifest, visit_number):
170
+ return Decision(updated)
171
+ visit = visit_by_number(updated, visit_number)
172
+ visit["status"] = "active"
173
+ visit["mutation_baseline"] = copy.deepcopy(mutation_baseline)
174
+ visit["started_at"] = started_at
175
+ updated["status"] = "active"
176
+ return Decision(updated, "visit-activated", visit_number,
177
+ {"phase_id": visit["phase_id"]})
178
+
179
+
180
+ def require_incomplete(manifest: RunRecord, visit_number: int) -> None:
181
+ if visit_by_number(manifest, visit_number)["status"] == "completed":
182
+ raise RunStoreError(
183
+ f"visit {visit_number} is already completed",
184
+ diagnostic_code="visit-already-completed",
185
+ details={"visit_number": visit_number},
186
+ code=ExitCode.ILLEGAL_TRANSITION,
187
+ )
188
+
189
+
190
+ def transition_needed(
191
+ manifest: RunRecord, visit_number: int, outcome: str, target: str | None,
192
+ ) -> bool:
193
+ """Check retry/status before reading output or observing clocks."""
194
+
195
+ existing = visit_by_number(manifest, visit_number)
196
+ if existing["status"] == "completed":
197
+ if (existing["chosen_outcome"] == outcome
198
+ and existing["transition_target"] == target):
199
+ return False
200
+ raise RunStoreError(
201
+ f"visit {visit_number} already completed with another transition",
202
+ diagnostic_code="visit-already-completed",
203
+ details={"visit_number": visit_number},
204
+ code=ExitCode.ILLEGAL_TRANSITION,
205
+ )
206
+ if existing["status"] not in {"active", "awaiting-approval"}:
207
+ raise RunStoreError(
208
+ f"visit {visit_number} cannot complete from {existing['status']}",
209
+ diagnostic_code="illegal-visit-status",
210
+ details={"visit_number": visit_number, "status": existing["status"]},
211
+ code=ExitCode.ILLEGAL_TRANSITION,
212
+ )
213
+ return True
214
+
215
+
216
+ def _record_evidence(
217
+ manifest: RunRecord, visit: VisitRecord, *, mutation_result: Any,
218
+ validation_checks: list[Any] | None, command_results: list[Any] | None,
219
+ ) -> None:
220
+ visit["mutation_result"] = copy.deepcopy(mutation_result)
221
+ visit["validation_checks"] = copy.deepcopy(list(validation_checks or []))
222
+ visit["command_results"] = copy.deepcopy(list(command_results or []))
223
+ if isinstance(mutation_result, dict) and isinstance(mutation_result.get("head"), dict):
224
+ manifest["git"]["head"] = copy.deepcopy(mutation_result["head"])
225
+
226
+
227
+ def seal_visit(
228
+ manifest: RunRecord, visit_number: int, *, output_sha256: str,
229
+ outcome: str | None, transition_target: str | None, completed_at: str,
230
+ mutation_result: Any = None, validation_checks: list[Any] | None = None,
231
+ command_results: list[Any] | None = None, deviations: list[Any] | None = None,
232
+ ) -> RunRecord:
233
+ """Propose a sealed record for completion or successor input resolution.
234
+
235
+ This intermediate record is never committed by a graph transition: the
236
+ store gathers successor evidence under the same lock, then finishes it.
237
+ """
238
+
239
+ require_incomplete(manifest, visit_number)
240
+ updated = copy.deepcopy(manifest)
241
+ visit = visit_by_number(updated, visit_number)
242
+ visit["status"] = "completed"
243
+ visit["output"]["sha256"] = output_sha256
244
+ _record_evidence(updated, visit, mutation_result=mutation_result,
245
+ validation_checks=validation_checks, command_results=command_results)
246
+ visit["chosen_outcome"] = outcome
247
+ visit["transition_target"] = transition_target
248
+ visit["completed_at"] = completed_at
249
+ if deviations is not None:
250
+ visit["deviations"] = copy.deepcopy(list(deviations))
251
+ return updated
252
+
253
+
254
+ def completion_decision(sealed: RunRecord, visit_number: int) -> Decision:
255
+ visit = visit_by_number(sealed, visit_number)
256
+ return Decision(copy.deepcopy(sealed), "visit-completed", visit_number,
257
+ {"phase_id": visit["phase_id"], "outcome": visit["chosen_outcome"]})
258
+
259
+
260
+ def blocked_transition(
261
+ manifest: RunRecord, visit_number: int, reason: dict[str, Any], *,
262
+ mutation_result: Any = None, validation_checks: list[Any] | None = None,
263
+ command_results: list[Any] | None = None,
264
+ ) -> Decision:
265
+ updated = copy.deepcopy(manifest)
266
+ visit = visit_by_number(updated, visit_number)
267
+ visit["status"] = "blocked"
268
+ _record_evidence(updated, visit, mutation_result=mutation_result,
269
+ validation_checks=validation_checks, command_results=command_results)
270
+ updated["status"] = "blocked"
271
+ updated["block_reason"] = copy.deepcopy(reason)
272
+ return Decision(updated, "loop-limit-exceeded", visit_number, copy.deepcopy(reason))
273
+
274
+
275
+ def finish_transition(
276
+ sealed: RunRecord, visit_number: int, *, next_visit: VisitRecord | None,
277
+ pause: bool, terminal_result: Any = None,
278
+ ) -> Decision:
279
+ updated = copy.deepcopy(sealed)
280
+ visit = visit_by_number(updated, visit_number)
281
+ if next_visit is None:
282
+ updated["status"] = "completed"
283
+ updated["terminal_result"] = copy.deepcopy(
284
+ terminal_result if terminal_result is not None
285
+ else {"outcome": visit["chosen_outcome"], "visit_number": visit_number}
286
+ )
287
+ else:
288
+ updated["visits"].append(copy.deepcopy(next_visit))
289
+ updated["current_visit"] = next_visit["ordinal"]
290
+ updated["status"] = "paused" if pause else "awaiting-agent"
291
+ return Decision(updated, "visit-transitioned", visit_number, {
292
+ "phase_id": visit["phase_id"], "outcome": visit["chosen_outcome"],
293
+ "target": visit["transition_target"], "paused": pause,
294
+ "next_visit": next_visit["ordinal"] if next_visit is not None else None,
295
+ })
296
+
297
+
298
+ def resume_paused(manifest: RunRecord) -> Decision:
299
+ updated = copy.deepcopy(manifest)
300
+ if manifest["status"] != "paused":
301
+ return Decision(updated)
302
+ visit_number = manifest["current_visit"]
303
+ if visit_number is None:
304
+ raise RunCorruptionError("paused run has no current visit", run_id=manifest["run_id"])
305
+ current = visit_by_number(manifest, visit_number)
306
+ if current["status"] != "pending":
307
+ raise RunCorruptionError(
308
+ "paused run does not point to a pending visit", run_id=manifest["run_id"]
309
+ )
310
+ updated["status"] = "awaiting-agent"
311
+ return Decision(updated, "run-resumed", visit_number, {"phase_id": current["phase_id"]})
@@ -0,0 +1,18 @@
1
+ """Resolve the version from this source checkout or installed metadata."""
2
+
3
+ from importlib.metadata import version
4
+ from pathlib import Path
5
+ import tomllib
6
+
7
+
8
+ def get_version() -> str:
9
+ package = Path(__file__).resolve().parent
10
+ project_file = package.parent.parent / "pyproject.toml"
11
+ # Only the producer's src layout can supply checkout metadata. Never search
12
+ # the working directory or prefer an unrelated installed distribution.
13
+ if package.parent.name == "src" and project_file.is_file():
14
+ with project_file.open("rb") as stream:
15
+ project = tomllib.load(stream).get("project", {})
16
+ if project.get("name") == "specromancy-engine":
17
+ return project["version"]
18
+ return version("specromancy-engine")
@@ -0,0 +1,118 @@
1
+ """Stable action packets for harness-neutral phase execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ._config.config_models import PipelineConfig
10
+ from ._runs.run_records import RunRecord, SealedArtifactRecord, VisitRecord
11
+
12
+
13
+ ACTION_PACKET_SCHEMA_VERSION = 1
14
+
15
+
16
+ def build_action_packet(
17
+ pipeline: PipelineConfig,
18
+ manifest: RunRecord,
19
+ visit: VisitRecord,
20
+ ) -> dict[str, Any]:
21
+ """Return a fully resolved, versioned packet for one visit."""
22
+
23
+ phase = pipeline.phase(visit["phase_id"])
24
+ run_directory = (
25
+ pipeline.repository_root / ".specromancy" / "runs" / manifest["run_id"]
26
+ )
27
+ validate_command = [
28
+ "specromancy",
29
+ "--root",
30
+ str(pipeline.repository_root),
31
+ "validate",
32
+ manifest["run_id"],
33
+ phase.id,
34
+ ]
35
+ return {
36
+ "schema_version": ACTION_PACKET_SCHEMA_VERSION,
37
+ "run_id": manifest["run_id"],
38
+ "visit_id": visit["ordinal"],
39
+ "visit_attempt": visit["attempt"],
40
+ "visit_status": visit["status"],
41
+ "phase": phase.id,
42
+ "skill": _repository_record(pipeline.repository_root, visit["skill"]),
43
+ "inputs": [
44
+ {
45
+ **record,
46
+ "absolute_path": str(run_directory / record["path"]),
47
+ }
48
+ for record in visit["inputs"]
49
+ ],
50
+ "output": {
51
+ **visit["output"],
52
+ "absolute_path": str(run_directory / visit["output"]["path"]),
53
+ },
54
+ "template": (
55
+ _repository_record(pipeline.repository_root, visit["template"])
56
+ if visit["template"] is not None
57
+ else None
58
+ ),
59
+ "mutation": {
60
+ "policy": phase.mutation,
61
+ "allowlist": list(phase.allowlist),
62
+ },
63
+ "completion_criteria": list(phase.completion_criteria),
64
+ "validation": {
65
+ "type": phase.validator.type,
66
+ "required_headings": list(phase.validator.required_headings),
67
+ "heading_occurrence": phase.validator.heading_occurrence,
68
+ "schema": phase.validator.declared_path,
69
+ "commands": [
70
+ {
71
+ "argv": list(command.argv),
72
+ "timeout_seconds": command.timeout_seconds,
73
+ "required": command.required,
74
+ }
75
+ for command in phase.validation_commands
76
+ ],
77
+ },
78
+ "approval_conditions": list(phase.approval_conditions),
79
+ "stop_conditions": list(phase.stop_conditions),
80
+ "outcomes": [transition.outcome for transition in phase.transitions],
81
+ "final_validation_command": validate_command,
82
+ }
83
+
84
+
85
+ def render_action_packet(packet: dict[str, Any]) -> str:
86
+ """Render a concise terminal representation without losing exact paths."""
87
+
88
+ inputs = "\n".join(
89
+ f"- {item['reference']}: {item['absolute_path']} ({item['sha256']})"
90
+ for item in packet["inputs"]
91
+ )
92
+ criteria = "\n".join(
93
+ f"- {criterion}" for criterion in packet["completion_criteria"]
94
+ )
95
+ mutation = packet["mutation"]["policy"]
96
+ allowlist = packet["mutation"]["allowlist"]
97
+ if allowlist:
98
+ mutation += f" ({', '.join(allowlist)})"
99
+ return "\n".join(
100
+ (
101
+ f"Run: {packet['run_id']}",
102
+ f"Visit: {packet['visit_id']} ({packet['visit_status']})",
103
+ f"Phase: {packet['phase']}",
104
+ f"Skill: {packet['skill']['absolute_path']}",
105
+ "Inputs:",
106
+ inputs or "- none",
107
+ f"Output: {packet['output']['absolute_path']}",
108
+ f"Mutation: {mutation}",
109
+ "Completion criteria:",
110
+ criteria or "- none",
111
+ "Validate: "
112
+ + shlex.join(str(part) for part in packet["final_validation_command"]),
113
+ )
114
+ )
115
+
116
+
117
+ def _repository_record(root: Path, record: SealedArtifactRecord) -> dict[str, Any]:
118
+ return {**record, "absolute_path": str(root / record["path"])}
@@ -0,0 +1,97 @@
1
+ """Deterministic, safely owned adapters for supported agent harnesses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ._adapters.adapter_contracts import (
10
+ ADAPTER_COMMANDS, ADAPTER_SCHEMA_VERSION, GENERATOR_VERSION, HARNESS_MODES,
11
+ MANIFEST_PATH, REGENERATION_COMMAND, AdapterCommand, AdapterError,
12
+ )
13
+ from ._adapters.adapter_ownership import _compare, _has_drift, write_adapters
14
+ from ._adapters.adapter_rendering import expected_manifest, render_sources
15
+ from ._adapters.adapter_sources import capture_sources
16
+ from .exit_codes import ExitCode
17
+ from .hashing import canonical_json_bytes
18
+ from .registry import PipelineRegistry
19
+
20
+
21
+ def generate_adapters(
22
+ repository_root: Path,
23
+ registry: PipelineRegistry,
24
+ command_metadata: Mapping[str, Any] | None = None,
25
+ *,
26
+ check: bool = False,
27
+ ) -> dict[str, Any]:
28
+ """Generate adapters or compare the worktree to deterministic output.
29
+
30
+ Omitted metadata is derived from the graphs captured for this invocation.
31
+ Explicit caller-supplied metadata retains its existing hash contract.
32
+ """
33
+
34
+ root = repository_root.resolve(strict=True)
35
+ sources = capture_sources(root, registry, command_metadata)
36
+ rendered = render_sources(sources)
37
+ manifest = expected_manifest(sources, rendered)
38
+ manifest_bytes = canonical_json_bytes(manifest) + b"\n"
39
+
40
+ if check:
41
+ drift = _compare(root, rendered, manifest_bytes)
42
+ if _has_drift(drift):
43
+ raise AdapterError("generated adapter drift detected", drift)
44
+ return _result(
45
+ "generated adapters are up to date",
46
+ checked=True,
47
+ paths=tuple(rendered),
48
+ canonical_source_sha256=manifest["canonical_source"][
49
+ "sha256"
50
+ ],
51
+ )
52
+
53
+ removed = write_adapters(root, rendered, manifest_bytes)
54
+ return _result(
55
+ f"generated {len(rendered)} harness adapter files",
56
+ checked=False,
57
+ paths=tuple(rendered),
58
+ canonical_source_sha256=manifest["canonical_source"]["sha256"],
59
+ removed=removed,
60
+ )
61
+
62
+
63
+ def render_adapters(
64
+ repository_root: Path,
65
+ registry: PipelineRegistry,
66
+ command_metadata: Mapping[str, Any],
67
+ ) -> dict[str, bytes]:
68
+ """Render all generated adapter files in stable path order."""
69
+
70
+ root = repository_root.resolve(strict=True)
71
+ sources = capture_sources(
72
+ root, registry, command_metadata, include_manifest_inputs=False
73
+ )
74
+ return render_sources(sources)
75
+
76
+
77
+ def _result(
78
+ message: str,
79
+ *,
80
+ checked: bool,
81
+ paths: tuple[str, ...],
82
+ canonical_source_sha256: str,
83
+ removed: tuple[str, ...] = (),
84
+ ) -> dict[str, Any]:
85
+ return {
86
+ "schema_version": 1,
87
+ "kind": "adapters",
88
+ "code": int(ExitCode.SUCCESS),
89
+ "message": message,
90
+ "adapters": {
91
+ "checked": checked,
92
+ "manifest": MANIFEST_PATH,
93
+ "canonical_source_sha256": canonical_source_sha256,
94
+ "paths": list(paths),
95
+ "removed": list(removed),
96
+ },
97
+ }