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,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,8 @@
1
+ """Module entry point for ``python -m specromancy_engine``."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
8
+
@@ -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
+ }