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,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
|
+
}
|