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,32 @@
|
|
|
1
|
+
"""Harness-agnostic agentic pipeline framework."""
|
|
2
|
+
|
|
3
|
+
from ._version import get_version as _get_version
|
|
4
|
+
from .config import PipelineConfig, PipelineConfigError, load_pipeline
|
|
5
|
+
from .engine import Engine, EngineError
|
|
6
|
+
from .exit_codes import ExitCode
|
|
7
|
+
from .registry import PipelineRegistry, PipelineRegistration, load_registry
|
|
8
|
+
from .run_store import (
|
|
9
|
+
RunCorruptionError,
|
|
10
|
+
RunNotFoundError,
|
|
11
|
+
RunStore,
|
|
12
|
+
generate_run_id,
|
|
13
|
+
validate_run_id,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ExitCode",
|
|
18
|
+
"Engine",
|
|
19
|
+
"EngineError",
|
|
20
|
+
"PipelineConfig",
|
|
21
|
+
"PipelineConfigError",
|
|
22
|
+
"PipelineRegistry",
|
|
23
|
+
"PipelineRegistration",
|
|
24
|
+
"RunCorruptionError",
|
|
25
|
+
"RunNotFoundError",
|
|
26
|
+
"RunStore",
|
|
27
|
+
"generate_run_id",
|
|
28
|
+
"load_pipeline",
|
|
29
|
+
"load_registry",
|
|
30
|
+
"validate_run_id",
|
|
31
|
+
]
|
|
32
|
+
__version__ = _get_version()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private adapters implementation components; use the public facades."""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Shared adapter contracts, re-exported by the public adapters facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..errors import SpecromancyError
|
|
10
|
+
from ..exit_codes import ExitCode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ADAPTER_SCHEMA_VERSION = 1
|
|
14
|
+
GENERATOR_VERSION = 2
|
|
15
|
+
MANIFEST_PATH = "adapters/manifest.json"
|
|
16
|
+
REGENERATION_COMMAND = "specromancy adapters generate"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class AdapterCommand:
|
|
21
|
+
"""A thin harness command that delegates to the stable CLI."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
cli_command: str
|
|
25
|
+
arguments: str
|
|
26
|
+
description: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ADAPTER_COMMANDS = (
|
|
30
|
+
AdapterCommand(
|
|
31
|
+
"init",
|
|
32
|
+
"init",
|
|
33
|
+
"PIPELINE_ID DESCRIPTION",
|
|
34
|
+
"Initialize a Specromancy run and return its recorded next action.",
|
|
35
|
+
),
|
|
36
|
+
AdapterCommand(
|
|
37
|
+
"resume",
|
|
38
|
+
"resume",
|
|
39
|
+
"RUN_ID",
|
|
40
|
+
"Resume a Specromancy run from persisted state.",
|
|
41
|
+
),
|
|
42
|
+
AdapterCommand(
|
|
43
|
+
"status",
|
|
44
|
+
"status",
|
|
45
|
+
"RUN_ID",
|
|
46
|
+
"Read a Specromancy run's persisted status and next command.",
|
|
47
|
+
),
|
|
48
|
+
AdapterCommand(
|
|
49
|
+
"phase",
|
|
50
|
+
"phase",
|
|
51
|
+
"RUN_ID PHASE",
|
|
52
|
+
"Start or resume the named current phase and return its action packet.",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
HARNESS_MODES = {
|
|
57
|
+
"claude": "generated",
|
|
58
|
+
"codex": "native",
|
|
59
|
+
"copilot": "generated",
|
|
60
|
+
"hermes": "native",
|
|
61
|
+
"opencode": "generated",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AdapterError(SpecromancyError):
|
|
66
|
+
"""Report adapter drift or an ownership conflict without unsafe writes."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, message: str, details: Mapping[str, Any]) -> None:
|
|
69
|
+
super().__init__(ExitCode.ADAPTER_DRIFT, message, details)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _target_for_path(path: str) -> str:
|
|
73
|
+
if path.startswith(".agents/"):
|
|
74
|
+
return "codex"
|
|
75
|
+
if path.startswith(".claude/"):
|
|
76
|
+
return "claude"
|
|
77
|
+
if path.startswith(".github/"):
|
|
78
|
+
return "copilot"
|
|
79
|
+
if path.startswith(".opencode/"):
|
|
80
|
+
return "opencode"
|
|
81
|
+
raise AdapterError("generated path has no target harness", {"path": path})
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Manifest ownership, drift checks, preflight, and per-file atomic writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .adapter_contracts import ADAPTER_SCHEMA_VERSION, MANIFEST_PATH, AdapterError, _target_for_path
|
|
13
|
+
from ..hashing import SHA256_PATTERN, normalize_relative_path, sha256_bytes
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def write_adapters(
|
|
17
|
+
root: Path, rendered: Mapping[str, bytes], manifest_bytes: bytes
|
|
18
|
+
) -> tuple[str, ...]:
|
|
19
|
+
"""Preflight ownership, replace outputs, recheck stale hashes, then write manifest."""
|
|
20
|
+
|
|
21
|
+
previous = _read_previous_manifest(root)
|
|
22
|
+
conflicts, stale = _preflight_generation(root, rendered, previous)
|
|
23
|
+
if conflicts:
|
|
24
|
+
raise AdapterError(
|
|
25
|
+
"adapter generation stopped to protect unowned or modified files",
|
|
26
|
+
conflicts,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
for path, content in rendered.items():
|
|
30
|
+
target = _target(root, path)
|
|
31
|
+
if target.is_file() and not target.is_symlink():
|
|
32
|
+
try:
|
|
33
|
+
if target.read_bytes() == content:
|
|
34
|
+
continue
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
raise AdapterError(
|
|
37
|
+
"cannot read generated adapter destination",
|
|
38
|
+
{"path": path, "error": str(exc)},
|
|
39
|
+
) from exc
|
|
40
|
+
_atomic_write(root, path, content)
|
|
41
|
+
|
|
42
|
+
for path, expected_hash in stale:
|
|
43
|
+
target = _target(root, path)
|
|
44
|
+
if not target.exists() and not target.is_symlink():
|
|
45
|
+
continue
|
|
46
|
+
if target.is_symlink() or not target.is_file():
|
|
47
|
+
raise AdapterError(
|
|
48
|
+
"stale generated adapter changed during generation",
|
|
49
|
+
{"stale_modified": [path]},
|
|
50
|
+
)
|
|
51
|
+
current = sha256_bytes(target.read_bytes())
|
|
52
|
+
if current != expected_hash:
|
|
53
|
+
raise AdapterError(
|
|
54
|
+
"stale generated adapter changed during generation",
|
|
55
|
+
{"stale_modified": [path]},
|
|
56
|
+
)
|
|
57
|
+
target.unlink()
|
|
58
|
+
|
|
59
|
+
_atomic_write(root, MANIFEST_PATH, manifest_bytes)
|
|
60
|
+
return tuple(path for path, _ in stale)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_previous_manifest(root: Path) -> dict[str, Any] | None:
|
|
64
|
+
path = _target(root, MANIFEST_PATH)
|
|
65
|
+
if not path.exists() and not path.is_symlink():
|
|
66
|
+
return None
|
|
67
|
+
if path.is_symlink() or not path.is_file():
|
|
68
|
+
raise AdapterError("adapter manifest is not a regular file", {"path": MANIFEST_PATH})
|
|
69
|
+
try:
|
|
70
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
71
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
72
|
+
raise AdapterError(
|
|
73
|
+
"adapter manifest is unreadable or invalid",
|
|
74
|
+
{"path": MANIFEST_PATH, "error": str(exc)},
|
|
75
|
+
) from exc
|
|
76
|
+
_owned_paths(value)
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _owned_paths(manifest: Mapping[str, Any]) -> dict[str, str]:
|
|
81
|
+
if manifest.get("schema_version") != ADAPTER_SCHEMA_VERSION:
|
|
82
|
+
raise AdapterError(
|
|
83
|
+
"unsupported adapter manifest schema",
|
|
84
|
+
{"path": MANIFEST_PATH, "schema_version": manifest.get("schema_version")},
|
|
85
|
+
)
|
|
86
|
+
generated = manifest.get("generated")
|
|
87
|
+
if not isinstance(generated, list):
|
|
88
|
+
raise AdapterError("adapter manifest has no generated path list", {"path": MANIFEST_PATH})
|
|
89
|
+
owned: dict[str, str] = {}
|
|
90
|
+
for item in generated:
|
|
91
|
+
if not isinstance(item, Mapping):
|
|
92
|
+
raise AdapterError(
|
|
93
|
+
"adapter manifest has an invalid generated entry",
|
|
94
|
+
{"path": MANIFEST_PATH},
|
|
95
|
+
)
|
|
96
|
+
path = item.get("path")
|
|
97
|
+
digest = item.get("sha256")
|
|
98
|
+
try:
|
|
99
|
+
normalized = normalize_relative_path(path)
|
|
100
|
+
except (TypeError, ValueError) as exc:
|
|
101
|
+
raise AdapterError(
|
|
102
|
+
"adapter manifest contains an unsafe path",
|
|
103
|
+
{"path": path, "error": str(exc)},
|
|
104
|
+
) from exc
|
|
105
|
+
if normalized == MANIFEST_PATH or normalized in owned:
|
|
106
|
+
raise AdapterError(
|
|
107
|
+
"adapter manifest contains a duplicate or self-owned path",
|
|
108
|
+
{"path": normalized},
|
|
109
|
+
)
|
|
110
|
+
if not _is_managed_adapter_path(normalized):
|
|
111
|
+
raise AdapterError(
|
|
112
|
+
"adapter manifest contains a path outside generated adapter locations",
|
|
113
|
+
{"path": normalized},
|
|
114
|
+
)
|
|
115
|
+
target = item.get("target")
|
|
116
|
+
if target != _target_for_path(normalized):
|
|
117
|
+
raise AdapterError(
|
|
118
|
+
"adapter manifest target does not match its generated path",
|
|
119
|
+
{"path": normalized, "target": target},
|
|
120
|
+
)
|
|
121
|
+
if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest):
|
|
122
|
+
raise AdapterError(
|
|
123
|
+
"adapter manifest contains an invalid content hash",
|
|
124
|
+
{"path": normalized, "sha256": digest},
|
|
125
|
+
)
|
|
126
|
+
owned[normalized] = digest
|
|
127
|
+
return owned
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _preflight_generation(
|
|
131
|
+
root: Path,
|
|
132
|
+
rendered: Mapping[str, bytes],
|
|
133
|
+
previous: Mapping[str, Any] | None,
|
|
134
|
+
) -> tuple[dict[str, Any], list[tuple[str, str]]]:
|
|
135
|
+
owned = {} if previous is None else _owned_paths(previous)
|
|
136
|
+
expected = set(rendered)
|
|
137
|
+
conflicts: dict[str, Any] = {}
|
|
138
|
+
unowned = []
|
|
139
|
+
unsafe = []
|
|
140
|
+
for path in sorted(expected):
|
|
141
|
+
target = _target(root, path)
|
|
142
|
+
problem = _unsafe_existing_path(root, target)
|
|
143
|
+
if problem is not None:
|
|
144
|
+
unsafe.append({"path": path, "reason": problem})
|
|
145
|
+
elif (target.exists() or target.is_symlink()) and path not in owned:
|
|
146
|
+
unowned.append(path)
|
|
147
|
+
|
|
148
|
+
unexpected = sorted(_managed_paths(root) - expected - set(owned))
|
|
149
|
+
if unexpected:
|
|
150
|
+
unowned.extend(path for path in unexpected if path not in unowned)
|
|
151
|
+
|
|
152
|
+
stale: list[tuple[str, str]] = []
|
|
153
|
+
stale_modified = []
|
|
154
|
+
for path in sorted(set(owned) - expected):
|
|
155
|
+
target = _target(root, path)
|
|
156
|
+
if not target.exists() and not target.is_symlink():
|
|
157
|
+
continue
|
|
158
|
+
problem = _unsafe_existing_path(root, target)
|
|
159
|
+
if problem is not None or target.is_symlink() or not target.is_file():
|
|
160
|
+
stale_modified.append(path)
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
current_hash = sha256_bytes(target.read_bytes())
|
|
164
|
+
except OSError:
|
|
165
|
+
stale_modified.append(path)
|
|
166
|
+
continue
|
|
167
|
+
if current_hash == owned[path]:
|
|
168
|
+
stale.append((path, owned[path]))
|
|
169
|
+
else:
|
|
170
|
+
stale_modified.append(path)
|
|
171
|
+
|
|
172
|
+
if unowned:
|
|
173
|
+
conflicts["unowned"] = sorted(unowned)
|
|
174
|
+
if unsafe:
|
|
175
|
+
conflicts["unsafe"] = unsafe
|
|
176
|
+
if stale_modified:
|
|
177
|
+
conflicts["stale_modified"] = stale_modified
|
|
178
|
+
return conflicts, stale
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _compare(
|
|
182
|
+
root: Path, rendered: Mapping[str, bytes], manifest_bytes: bytes
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
missing = []
|
|
185
|
+
modified = []
|
|
186
|
+
for path, expected in (*rendered.items(), (MANIFEST_PATH, manifest_bytes)):
|
|
187
|
+
target = _target(root, path)
|
|
188
|
+
if not target.exists() and not target.is_symlink():
|
|
189
|
+
missing.append(path)
|
|
190
|
+
continue
|
|
191
|
+
if target.is_symlink() or not target.is_file():
|
|
192
|
+
modified.append(path)
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
actual = target.read_bytes()
|
|
196
|
+
except OSError:
|
|
197
|
+
modified.append(path)
|
|
198
|
+
continue
|
|
199
|
+
if actual != expected:
|
|
200
|
+
modified.append(path)
|
|
201
|
+
unexpected = sorted(_managed_paths(root) - set(rendered))
|
|
202
|
+
return {
|
|
203
|
+
"missing": sorted(missing),
|
|
204
|
+
"modified": sorted(modified),
|
|
205
|
+
"unexpected": unexpected,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _has_drift(drift: Mapping[str, Any]) -> bool:
|
|
210
|
+
return any(bool(value) for value in drift.values())
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _managed_paths(root: Path) -> set[str]:
|
|
214
|
+
paths: set[str] = set()
|
|
215
|
+
singles = (".claude/CLAUDE.md", ".github/copilot-instructions.md")
|
|
216
|
+
for path in singles:
|
|
217
|
+
target = root / path
|
|
218
|
+
if target.exists() or target.is_symlink():
|
|
219
|
+
paths.add(path)
|
|
220
|
+
scans = (
|
|
221
|
+
(root / ".agents" / "skills", "specromancy-*/SKILL.md"),
|
|
222
|
+
(root / ".claude" / "skills", "*/SKILL.md"),
|
|
223
|
+
(root / ".claude" / "commands" / "specromancy", "*.md"),
|
|
224
|
+
(root / ".github" / "prompts", "specromancy-*.prompt.md"),
|
|
225
|
+
(root / ".opencode" / "commands", "specromancy-*.md"),
|
|
226
|
+
)
|
|
227
|
+
for directory, pattern in scans:
|
|
228
|
+
if not directory.is_dir() or directory.is_symlink():
|
|
229
|
+
continue
|
|
230
|
+
for candidate in directory.glob(pattern):
|
|
231
|
+
if candidate.is_file() or candidate.is_symlink():
|
|
232
|
+
try:
|
|
233
|
+
paths.add(candidate.relative_to(root).as_posix())
|
|
234
|
+
except ValueError:
|
|
235
|
+
continue
|
|
236
|
+
return paths
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _is_managed_adapter_path(path: str) -> bool:
|
|
240
|
+
parts = Path(path).parts
|
|
241
|
+
if path in {".claude/CLAUDE.md", ".github/copilot-instructions.md"}:
|
|
242
|
+
return True
|
|
243
|
+
if len(parts) == 4 and parts[:2] == (".agents", "skills"):
|
|
244
|
+
return parts[2].startswith("specromancy-") and parts[3] == "SKILL.md"
|
|
245
|
+
if len(parts) == 4 and parts[:2] == (".claude", "skills"):
|
|
246
|
+
return parts[3] == "SKILL.md"
|
|
247
|
+
if len(parts) == 4 and parts[:3] == (".claude", "commands", "specromancy"):
|
|
248
|
+
return parts[3].endswith(".md")
|
|
249
|
+
if len(parts) == 3 and parts[:2] == (".github", "prompts"):
|
|
250
|
+
return parts[2].startswith("specromancy-") and parts[2].endswith(
|
|
251
|
+
".prompt.md"
|
|
252
|
+
)
|
|
253
|
+
if len(parts) == 3 and parts[:2] == (".opencode", "commands"):
|
|
254
|
+
return parts[2].startswith("specromancy-") and parts[2].endswith(".md")
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _unsafe_existing_path(root: Path, target: Path) -> str | None:
|
|
259
|
+
current = root
|
|
260
|
+
for part in target.relative_to(root).parts[:-1]:
|
|
261
|
+
current = current / part
|
|
262
|
+
if current.is_symlink():
|
|
263
|
+
return "parent-is-symlink"
|
|
264
|
+
if current.exists() and not current.is_dir():
|
|
265
|
+
return "parent-is-not-directory"
|
|
266
|
+
if target.is_symlink():
|
|
267
|
+
return "target-is-symlink"
|
|
268
|
+
if target.exists() and not target.is_file():
|
|
269
|
+
return "target-is-not-file"
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _target(root: Path, relative: str) -> Path:
|
|
274
|
+
normalized = normalize_relative_path(relative)
|
|
275
|
+
return root / normalized
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _atomic_write(root: Path, relative: str, content: bytes) -> None:
|
|
279
|
+
target = _target(root, relative)
|
|
280
|
+
problem = _unsafe_existing_path(root, target)
|
|
281
|
+
if problem is not None:
|
|
282
|
+
raise AdapterError(
|
|
283
|
+
"generated adapter destination is unsafe",
|
|
284
|
+
{"path": relative, "reason": problem},
|
|
285
|
+
)
|
|
286
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
288
|
+
prefix=f".{target.name}.", suffix=".tmp", dir=target.parent
|
|
289
|
+
)
|
|
290
|
+
temporary = Path(temporary_name)
|
|
291
|
+
try:
|
|
292
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
293
|
+
stream.write(content)
|
|
294
|
+
stream.flush()
|
|
295
|
+
os.fsync(stream.fileno())
|
|
296
|
+
os.replace(temporary, target)
|
|
297
|
+
except Exception:
|
|
298
|
+
try:
|
|
299
|
+
temporary.unlink()
|
|
300
|
+
except FileNotFoundError:
|
|
301
|
+
pass
|
|
302
|
+
raise
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Deterministic harness rendering and expected manifest construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from .adapter_contracts import (
|
|
10
|
+
ADAPTER_COMMANDS, ADAPTER_SCHEMA_VERSION, GENERATOR_VERSION, HARNESS_MODES,
|
|
11
|
+
MANIFEST_PATH, REGENERATION_COMMAND, AdapterCommand, AdapterError, _target_for_path,
|
|
12
|
+
)
|
|
13
|
+
from ..hashing import sha256_bytes, sha256_json
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .adapter_sources import AdapterSources
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_sources(sources: AdapterSources) -> dict[str, bytes]:
|
|
20
|
+
"""Render captured sources without reading the filesystem or registry."""
|
|
21
|
+
|
|
22
|
+
files: dict[str, bytes] = {}
|
|
23
|
+
files[".claude/CLAUDE.md"] = _markdown_document(
|
|
24
|
+
"AGENTS.md",
|
|
25
|
+
sources.agents,
|
|
26
|
+
preface="The following repository instructions mirror the canonical AGENTS.md.\n\n",
|
|
27
|
+
)
|
|
28
|
+
for skill_path, skill_text in sources.skills:
|
|
29
|
+
name = Path(skill_path).parent.name
|
|
30
|
+
files[f".claude/skills/{name}/SKILL.md"] = _skill_mirror(
|
|
31
|
+
skill_path, skill_text
|
|
32
|
+
)
|
|
33
|
+
for pipeline in sources.pipelines:
|
|
34
|
+
name = f"specromancy-{pipeline.id}"
|
|
35
|
+
skill = _pipeline_skill(pipeline.id)
|
|
36
|
+
files[f".agents/skills/{name}/SKILL.md"] = skill
|
|
37
|
+
files[f".claude/skills/{name}/SKILL.md"] = skill
|
|
38
|
+
for command in ADAPTER_COMMANDS:
|
|
39
|
+
files[f".claude/commands/specromancy/{command.name}.md"] = (
|
|
40
|
+
_command_wrapper("claude", command)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
files[".github/copilot-instructions.md"] = _markdown_document(
|
|
44
|
+
"AGENTS.md",
|
|
45
|
+
sources.agents,
|
|
46
|
+
preface=(
|
|
47
|
+
"These repository-work rules mirror the canonical instructions used by "
|
|
48
|
+
"Specromancy.\n\n"
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
for command in ADAPTER_COMMANDS:
|
|
52
|
+
files[f".github/prompts/specromancy-{command.name}.prompt.md"] = (
|
|
53
|
+
_command_wrapper("copilot", command)
|
|
54
|
+
)
|
|
55
|
+
files[f".opencode/commands/specromancy-{command.name}.md"] = (
|
|
56
|
+
_command_wrapper("opencode", command)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Sorting here is part of the public reproducibility guarantee.
|
|
60
|
+
return {path: files[path] for path in sorted(files)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _pipeline_skill(pipeline_id: str) -> bytes:
|
|
64
|
+
source = "workflow/pipelines.toml and .agents/skills/pipeline/SKILL.md"
|
|
65
|
+
lines = [
|
|
66
|
+
"---",
|
|
67
|
+
*_yaml_provenance(source),
|
|
68
|
+
f"name: specromancy-{pipeline_id}",
|
|
69
|
+
f"description: Coordinate the {pipeline_id} pipeline when the user requests this workflow.",
|
|
70
|
+
"license: MIT",
|
|
71
|
+
"compatibility: Specromancy pipeline schema version 1.",
|
|
72
|
+
"metadata:",
|
|
73
|
+
" role: orchestrator",
|
|
74
|
+
"---",
|
|
75
|
+
"",
|
|
76
|
+
f"# {pipeline_id}",
|
|
77
|
+
"",
|
|
78
|
+
f"For a new request, run `specromancy init DESCRIPTION --pipeline {pipeline_id} --json`.",
|
|
79
|
+
"Retain the emitted RUN_ID. For an existing invocation, obtain RUN_ID from the user.",
|
|
80
|
+
"Then load `.agents/skills/pipeline/SKILL.md` and follow its existing-run procedure",
|
|
81
|
+
"using that RUN_ID and the CLI's persisted status and action packets.",
|
|
82
|
+
"Never edit run.json or events.jsonl.",
|
|
83
|
+
"",
|
|
84
|
+
]
|
|
85
|
+
return "\n".join(lines).encode("utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _command_wrapper(harness: str, command: AdapterCommand) -> bytes:
|
|
89
|
+
source = "CLI help metadata and canonical .agents skills"
|
|
90
|
+
invocation = _harness_invocation(harness, command)
|
|
91
|
+
if command.name == "init":
|
|
92
|
+
follow = (
|
|
93
|
+
"Use the returned run ID and recorded next command. If the response contains "
|
|
94
|
+
"an action packet, load its `skill.absolute_path` and follow that canonical "
|
|
95
|
+
"procedure exactly."
|
|
96
|
+
)
|
|
97
|
+
elif command.name == "status":
|
|
98
|
+
follow = (
|
|
99
|
+
"Report the persisted status and recorded next command. Do not infer run state "
|
|
100
|
+
"from conversation history."
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
follow = (
|
|
104
|
+
"If the response contains an action packet, load its `skill.absolute_path` and "
|
|
105
|
+
"follow that canonical procedure exactly. Stop at any recorded pause, approval, "
|
|
106
|
+
"block, validation failure, or terminal result."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
frontmatter = [
|
|
110
|
+
"---",
|
|
111
|
+
*_yaml_provenance(source),
|
|
112
|
+
f"description: {command.description}",
|
|
113
|
+
]
|
|
114
|
+
if harness == "copilot":
|
|
115
|
+
frontmatter.append(f"argument-hint: {command.arguments}")
|
|
116
|
+
frontmatter.append("agent: agent")
|
|
117
|
+
elif harness == "claude":
|
|
118
|
+
frontmatter.append(f"argument-hint: {command.arguments}")
|
|
119
|
+
frontmatter.extend(["---", ""])
|
|
120
|
+
text = "\n".join(frontmatter)
|
|
121
|
+
if harness == "copilot":
|
|
122
|
+
text += (
|
|
123
|
+
f"Using the values supplied after this prompt's slash command, replace the "
|
|
124
|
+
f"placeholders in `{invocation}` and run it. {follow}\n"
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
text += f"Run `{invocation}`. {follow}\n"
|
|
128
|
+
return text.encode("utf-8")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _harness_invocation(harness: str, command: AdapterCommand) -> str:
|
|
132
|
+
if harness == "claude":
|
|
133
|
+
arguments = {
|
|
134
|
+
"init": '"$1" --pipeline "$0"',
|
|
135
|
+
"resume": '"$0"',
|
|
136
|
+
"status": '"$0"',
|
|
137
|
+
"phase": '"$0" "$1"',
|
|
138
|
+
}[command.name]
|
|
139
|
+
elif harness == "opencode":
|
|
140
|
+
arguments = {
|
|
141
|
+
"init": '"$2" --pipeline "$1"',
|
|
142
|
+
"resume": '"$1"',
|
|
143
|
+
"status": '"$1"',
|
|
144
|
+
"phase": '"$1" "$2"',
|
|
145
|
+
}[command.name]
|
|
146
|
+
else:
|
|
147
|
+
arguments = "DESCRIPTION --pipeline PIPELINE_ID" if command.name == "init" else command.arguments
|
|
148
|
+
return f"specromancy {command.cli_command} {arguments} --json"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _skill_mirror(source: str, text: str) -> bytes:
|
|
152
|
+
lines = text.splitlines()
|
|
153
|
+
if not lines or lines[0] != "---":
|
|
154
|
+
raise AdapterError(
|
|
155
|
+
"canonical skill has no portable frontmatter",
|
|
156
|
+
{"path": source},
|
|
157
|
+
)
|
|
158
|
+
mirrored = [lines[0], *_yaml_provenance(source), *lines[1:]]
|
|
159
|
+
return ("\n".join(mirrored) + "\n").encode("utf-8")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _markdown_document(source: str, body: str, *, preface: str = "") -> bytes:
|
|
163
|
+
header = (
|
|
164
|
+
f"<!-- Generated by Specromancy adapter generator v{GENERATOR_VERSION}.\n"
|
|
165
|
+
f"Canonical source: {source}.\n"
|
|
166
|
+
f"Regenerate: {REGENERATION_COMMAND}\n"
|
|
167
|
+
"Do not edit this file directly. -->\n\n"
|
|
168
|
+
)
|
|
169
|
+
return (header + preface + body).encode("utf-8")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _yaml_provenance(source: str) -> list[str]:
|
|
173
|
+
return [
|
|
174
|
+
f"# Generated by Specromancy adapter generator v{GENERATOR_VERSION}.",
|
|
175
|
+
f"# Canonical source: {source}.",
|
|
176
|
+
f"# Regenerate: {REGENERATION_COMMAND}",
|
|
177
|
+
"# Do not edit this file directly.",
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def expected_manifest(
|
|
182
|
+
bundle: AdapterSources,
|
|
183
|
+
rendered: Mapping[str, bytes],
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
sources = [source.record() for source in bundle.inputs]
|
|
186
|
+
generated = []
|
|
187
|
+
for path, content in rendered.items():
|
|
188
|
+
generated.append(
|
|
189
|
+
{
|
|
190
|
+
"path": path,
|
|
191
|
+
"sha256": sha256_bytes(content),
|
|
192
|
+
"target": _target_for_path(path),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
targets = []
|
|
196
|
+
for name in sorted(HARNESS_MODES):
|
|
197
|
+
paths = [item["path"] for item in generated if item["target"] == name]
|
|
198
|
+
targets.append({"name": name, "mode": HARNESS_MODES[name], "paths": paths})
|
|
199
|
+
if HARNESS_MODES[name] == "native":
|
|
200
|
+
targets[-1]["discovery_paths"] = [path for path in rendered if path.startswith(".agents/")]
|
|
201
|
+
return {
|
|
202
|
+
"schema_version": ADAPTER_SCHEMA_VERSION,
|
|
203
|
+
"generator": {
|
|
204
|
+
"name": "specromancy",
|
|
205
|
+
"version": GENERATOR_VERSION,
|
|
206
|
+
"command": REGENERATION_COMMAND,
|
|
207
|
+
},
|
|
208
|
+
"canonical_source": {
|
|
209
|
+
"sha256": sha256_json(sources),
|
|
210
|
+
"inputs": sources,
|
|
211
|
+
},
|
|
212
|
+
"manifest": {
|
|
213
|
+
"path": MANIFEST_PATH,
|
|
214
|
+
"self_hash_omitted": True,
|
|
215
|
+
},
|
|
216
|
+
"targets": targets,
|
|
217
|
+
"generated": generated,
|
|
218
|
+
}
|