enginery 0.1.0__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 (119) hide show
  1. enginery/__init__.py +14 -0
  2. enginery/adapters/__init__.py +10 -0
  3. enginery/adapters/armory.py +243 -0
  4. enginery/adapters/claude_code.py +371 -0
  5. enginery/adapters/github.py +885 -0
  6. enginery/adapters/local.py +432 -0
  7. enginery/adapters/omp.py +343 -0
  8. enginery/adapters/pypi.py +199 -0
  9. enginery/application/__init__.py +8 -0
  10. enginery/application/adapter_types.py +199 -0
  11. enginery/application/delivery_ports.py +219 -0
  12. enginery/application/work_ports.py +403 -0
  13. enginery/capabilities/__init__.py +11 -0
  14. enginery/capabilities/approval.py +69 -0
  15. enginery/capabilities/errors.py +63 -0
  16. enginery/capabilities/lock.py +129 -0
  17. enginery/capabilities/materialize.py +99 -0
  18. enginery/capabilities/resolver.py +205 -0
  19. enginery/capabilities/serialization.py +113 -0
  20. enginery/capabilities/signature.py +120 -0
  21. enginery/cli/__init__.py +7 -0
  22. enginery/cli/_exit_codes.py +38 -0
  23. enginery/cli/capability.py +78 -0
  24. enginery/cli/doctor.py +61 -0
  25. enginery/cli/ledger.py +43 -0
  26. enginery/cli/main.py +396 -0
  27. enginery/cli/outcome.py +169 -0
  28. enginery/cli/stage1.py +345 -0
  29. enginery/cli/stage2.py +71 -0
  30. enginery/domain/__init__.py +8 -0
  31. enginery/domain/artifact.py +72 -0
  32. enginery/domain/digests.py +62 -0
  33. enginery/domain/enums.py +32 -0
  34. enginery/domain/errors.py +129 -0
  35. enginery/domain/evidence.py +75 -0
  36. enginery/domain/factory_change.py +120 -0
  37. enginery/domain/ids.py +182 -0
  38. enginery/domain/immutable.py +60 -0
  39. enginery/domain/intervention.py +50 -0
  40. enginery/domain/node_attempt.py +206 -0
  41. enginery/domain/observation.py +120 -0
  42. enginery/domain/outcome.py +83 -0
  43. enginery/domain/plan_execution.py +275 -0
  44. enginery/domain/policy_decision.py +145 -0
  45. enginery/domain/principal.py +20 -0
  46. enginery/domain/run.py +174 -0
  47. enginery/domain/serialization.py +766 -0
  48. enginery/domain/stack.py +387 -0
  49. enginery/domain/state_machine.py +85 -0
  50. enginery/domain/work_item.py +145 -0
  51. enginery/domain/workflow/__init__.py +11 -0
  52. enginery/domain/workflow/budget.py +59 -0
  53. enginery/domain/workflow/manifest.py +310 -0
  54. enginery/domain/workflow/node.py +331 -0
  55. enginery/domain/workflow/schema.py +96 -0
  56. enginery/engine/__init__.py +8 -0
  57. enginery/engine/coordinator.py +343 -0
  58. enginery/engine/fixture_build.py +175 -0
  59. enginery/engine/leases.py +335 -0
  60. enginery/engine/omp_worker.py +115 -0
  61. enginery/engine/plan_execution.py +388 -0
  62. enginery/engine/recovery.py +185 -0
  63. enginery/engine/release_manifest.py +194 -0
  64. enginery/engine/results.py +44 -0
  65. enginery/engine/runtime.py +1158 -0
  66. enginery/engine/scheduler.py +199 -0
  67. enginery/engine/stack_coordinator.py +205 -0
  68. enginery/engine/supervisor.py +447 -0
  69. enginery/engine/workspace.py +409 -0
  70. enginery/evaluation/__init__.py +8 -0
  71. enginery/evaluation/outcomes.py +380 -0
  72. enginery/evaluation/queries.py +106 -0
  73. enginery/evidence/__init__.py +31 -0
  74. enginery/evidence/evaluator.py +128 -0
  75. enginery/evidence/terminal.py +307 -0
  76. enginery/ledger/__init__.py +69 -0
  77. enginery/ledger/artifact_store.py +130 -0
  78. enginery/ledger/artifacts.py +152 -0
  79. enginery/ledger/backup.py +137 -0
  80. enginery/ledger/commit_cursors.py +65 -0
  81. enginery/ledger/connection.py +69 -0
  82. enginery/ledger/errors.py +64 -0
  83. enginery/ledger/events.py +302 -0
  84. enginery/ledger/inbox.py +192 -0
  85. enginery/ledger/leases.py +142 -0
  86. enginery/ledger/migrations.py +68 -0
  87. enginery/ledger/outbox.py +90 -0
  88. enginery/ledger/process_manager.py +142 -0
  89. enginery/ledger/projections.py +222 -0
  90. enginery/ledger/redaction.py +105 -0
  91. enginery/ledger/schema.py +196 -0
  92. enginery/ledger/service.py +173 -0
  93. enginery/ledger/verify.py +120 -0
  94. enginery/plans/__init__.py +15 -0
  95. enginery/plans/loader.py +41 -0
  96. enginery/plans/model.py +313 -0
  97. enginery/plans/normalization.py +59 -0
  98. enginery/policy/__init__.py +20 -0
  99. enginery/policy/approval.py +212 -0
  100. enginery/policy/defaults.py +67 -0
  101. enginery/policy/evaluator.py +178 -0
  102. enginery/policy/rules.py +175 -0
  103. enginery/policy/schemas.py +195 -0
  104. enginery/py.typed +0 -0
  105. enginery/workflows/evidence.py +66 -0
  106. enginery/workflows/implementation.py +124 -0
  107. enginery/workflows/issue_to_pr.py +153 -0
  108. enginery/workflows/merge_policy.py +166 -0
  109. enginery/workflows/plan_to_release.py +206 -0
  110. enginery/workflows/pull_request.py +73 -0
  111. enginery/workflows/review.py +68 -0
  112. enginery/workflows/stage1.py +1284 -0
  113. enginery/workflows/stage1_runtime.py +222 -0
  114. enginery/workflows/verification.py +139 -0
  115. enginery-0.1.0.dist-info/METADATA +74 -0
  116. enginery-0.1.0.dist-info/RECORD +119 -0
  117. enginery-0.1.0.dist-info/WHEEL +4 -0
  118. enginery-0.1.0.dist-info/entry_points.txt +2 -0
  119. enginery-0.1.0.dist-info/licenses/LICENSE +201 -0
enginery/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Enginery: the control plane that turns engineering intent into verified outcomes.
2
+
3
+ ``__version__`` is read from installed package metadata rather than hardcoded
4
+ here, so ``pyproject.toml`` remains the single canonical source for the
5
+ project's version.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib import metadata
11
+
12
+ __version__ = metadata.version(__name__)
13
+
14
+ __all__ = ["__version__"]
@@ -0,0 +1,10 @@
1
+ """Provider implementations: work ledgers, harnesses, workspaces, SCM, CI,
2
+ release, and capability registries.
3
+
4
+ Rule: ``adapters`` may import ``domain``,
5
+ ``application``, ``engine``, ``ledger``, ``policy``, ``evidence``, and
6
+ ``evaluation``. Provider SDK objects never cross this boundary into inner
7
+ layers. ``adapters`` must not import ``cli``.
8
+ """
9
+
10
+ from __future__ import annotations
@@ -0,0 +1,243 @@
1
+ """Optional Armory capability-registry adapter.
2
+
3
+ Armory (`Mathews-Tom/armory`) is an external, GitHub-hosted catalog: a
4
+ single ``manifest.yaml`` fetched over HTTPS, with no MCP server, no
5
+ per-package signature, and no checksum field of its own. Content
6
+ addressing here comes entirely from this adapter's own fetch-and-hash
7
+ step, never from anything Armory asserts.
8
+
9
+ Armory is never a runtime dependency of the engine: importing this module
10
+ needs only the standard library, and every method that reaches the
11
+ network requires the optional ``armory`` extra (``pyyaml``) -- ``probe()``
12
+ reports ``AdapterAvailability.UNAVAILABLE`` when it is missing, exactly
13
+ like the OMP and Claude Code adapters report an absent CLI, so the engine
14
+ keeps working with Armory disabled.
15
+
16
+ Every capability this adapter returns reports ``provenance="armory"``,
17
+ which ``CapabilityResolver`` classifies as ``unauthenticated`` for any
18
+ run-introduced use unless a separate signature verifies -- Armory
19
+ supplies none today, so every Armory-sourced capability a run introduces
20
+ requires interactive exact-digest human approval before it can execute.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import urllib.error
26
+ import urllib.request
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass
29
+
30
+ from enginery.application.adapter_types import (
31
+ ADAPTER_API_VERSION,
32
+ AdapterAvailability,
33
+ AdapterCapability,
34
+ AdapterFingerprint,
35
+ AdapterStatus,
36
+ ProviderKind,
37
+ )
38
+ from enginery.application.delivery_ports import CapabilityDescriptor
39
+ from enginery.domain.digests import Digest
40
+ from enginery.domain.errors import MissingPrerequisiteError, TransientProviderFailureError
41
+
42
+ _DEFAULT_MANIFEST_URL = "https://raw.githubusercontent.com/Mathews-Tom/armory/main/manifest.yaml"
43
+ _DEFAULT_TIMEOUT_SECONDS = 10.0
44
+ _PROVENANCE_LABEL = "armory"
45
+ _PACKAGE_SECTIONS = ("skills", "agents", "hooks", "rules", "commands", "utilities", "presets")
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class ArmoryPackageEntry:
50
+ """One catalog row from ``manifest.yaml``."""
51
+
52
+ name: str
53
+ version: str
54
+ kind: str
55
+ source: str
56
+
57
+
58
+ def _default_fetcher(url: str, timeout: float) -> bytes:
59
+ request = urllib.request.Request(url, headers={"User-Agent": "enginery-armory-adapter"})
60
+ with urllib.request.urlopen(request, timeout=timeout) as response:
61
+ return bytes(response.read())
62
+
63
+
64
+ def _raw_content_url(blob_url: str) -> str:
65
+ """Turn a ``github.com/.../blob/<ref>/<path>`` URL into a raw-content URL."""
66
+
67
+ marker = "github.com/"
68
+ if marker not in blob_url or "/blob/" not in blob_url:
69
+ raise MissingPrerequisiteError(
70
+ "Armory manifest source is not a recognized GitHub blob URL",
71
+ details={"source": blob_url},
72
+ )
73
+ rest = blob_url.split(marker, 1)[1]
74
+ parts = rest.split("/", 3)
75
+ if len(parts) != 4 or parts[2] != "blob":
76
+ raise MissingPrerequisiteError(
77
+ "Armory manifest source is not a recognized GitHub blob URL",
78
+ details={"source": blob_url},
79
+ )
80
+ owner, repo, _blob, ref_and_path = parts
81
+ return f"https://raw.githubusercontent.com/{owner}/{repo}/{ref_and_path}"
82
+
83
+
84
+ def _license_url(manifest_url: str) -> str | None:
85
+ suffix = "manifest.yaml"
86
+ if not manifest_url.endswith(suffix):
87
+ return None
88
+ return manifest_url[: -len(suffix)] + "LICENSE"
89
+
90
+
91
+ def _detect_spdx_license(text: str) -> str:
92
+ head = text[:400]
93
+ if "MIT License" in head:
94
+ return "MIT"
95
+ if "Apache License" in head and "Version 2.0" in head:
96
+ return "Apache-2.0"
97
+ return "unknown"
98
+
99
+
100
+ def _parse_manifest(document: object) -> tuple[ArmoryPackageEntry, ...]:
101
+ if not isinstance(document, dict):
102
+ raise MissingPrerequisiteError("Armory manifest root must be a mapping")
103
+ packages = document.get("packages")
104
+ if not isinstance(packages, dict):
105
+ raise MissingPrerequisiteError("Armory manifest is missing a 'packages' mapping")
106
+ entries: list[ArmoryPackageEntry] = []
107
+ for kind, items in packages.items():
108
+ if kind not in _PACKAGE_SECTIONS or not isinstance(items, list):
109
+ continue
110
+ for item in items:
111
+ if not isinstance(item, dict):
112
+ continue
113
+ name, version, source = item.get("name"), item.get("version"), item.get("source")
114
+ if not isinstance(name, str) or not name.strip():
115
+ continue
116
+ if not isinstance(source, str) or not source.strip():
117
+ continue
118
+ version_text = str(version).strip() if version is not None else ""
119
+ if not version_text:
120
+ continue
121
+ entries.append(
122
+ ArmoryPackageEntry(name=name, version=version_text, kind=str(kind), source=source)
123
+ )
124
+ return tuple(entries)
125
+
126
+
127
+ class ArmoryCapabilitySource:
128
+ """Discover, resolve, and fetch capabilities from the Armory catalog."""
129
+
130
+ def __init__(
131
+ self,
132
+ *,
133
+ manifest_url: str = _DEFAULT_MANIFEST_URL,
134
+ timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
135
+ fetcher: Callable[[str, float], bytes] = _default_fetcher,
136
+ ) -> None:
137
+ self._manifest_url = manifest_url
138
+ self._timeout_seconds = timeout_seconds
139
+ self._fetcher = fetcher
140
+ self._license_cache: str | None = None
141
+
142
+ def probe(self) -> AdapterStatus:
143
+ try:
144
+ import yaml # noqa: F401 -- see module docstring: optional `armory` extra
145
+ except ImportError:
146
+ return AdapterStatus(
147
+ kind=ProviderKind.CAPABILITY_SOURCE,
148
+ availability=AdapterAvailability.UNAVAILABLE,
149
+ fingerprint=None,
150
+ detail="pyyaml is not installed; run `uv sync --extra armory` to enable Armory",
151
+ )
152
+ try:
153
+ self._entries()
154
+ except (TransientProviderFailureError, MissingPrerequisiteError) as error:
155
+ return AdapterStatus(
156
+ kind=ProviderKind.CAPABILITY_SOURCE,
157
+ availability=AdapterAvailability.MISCONFIGURED,
158
+ fingerprint=None,
159
+ detail=str(error),
160
+ )
161
+ return AdapterStatus(
162
+ kind=ProviderKind.CAPABILITY_SOURCE,
163
+ availability=AdapterAvailability.AVAILABLE,
164
+ fingerprint=AdapterFingerprint(
165
+ provider_id="armory-capability-source",
166
+ provider_version="1.0.0",
167
+ api_version=ADAPTER_API_VERSION,
168
+ capabilities=(
169
+ AdapterCapability(name="discover", version=1),
170
+ AdapterCapability(name="resolve", version=1),
171
+ AdapterCapability(name="fetch", version=1),
172
+ ),
173
+ ),
174
+ detail=f"armory manifest reachable at {self._manifest_url}",
175
+ )
176
+
177
+ def discover(self) -> tuple[CapabilityDescriptor, ...]:
178
+ return tuple(self._descriptor(entry) for entry in self._entries())
179
+
180
+ def resolve(self, name: str, version: str) -> CapabilityDescriptor | None:
181
+ entry = self._find(name, version)
182
+ return None if entry is None else self._descriptor(entry)
183
+
184
+ def fetch(self, name: str, version: str) -> bytes:
185
+ entry = self._find(name, version)
186
+ if entry is None:
187
+ raise MissingPrerequisiteError(
188
+ "capability was not found in the Armory manifest",
189
+ details={"name": name, "version": version},
190
+ )
191
+ return self._fetch_bytes(_raw_content_url(entry.source))
192
+
193
+ def _find(self, name: str, version: str) -> ArmoryPackageEntry | None:
194
+ return next(
195
+ (item for item in self._entries() if item.name == name and item.version == version),
196
+ None,
197
+ )
198
+
199
+ def _descriptor(self, entry: ArmoryPackageEntry) -> CapabilityDescriptor:
200
+ content = self._fetch_bytes(_raw_content_url(entry.source))
201
+ return CapabilityDescriptor(
202
+ name=entry.name,
203
+ version=entry.version,
204
+ digest=Digest.of_bytes(content),
205
+ provenance=_PROVENANCE_LABEL,
206
+ license=self._license(),
207
+ )
208
+
209
+ def _license(self) -> str:
210
+ if self._license_cache is not None:
211
+ return self._license_cache
212
+ url = _license_url(self._manifest_url)
213
+ if url is None:
214
+ self._license_cache = "unknown"
215
+ return self._license_cache
216
+ try:
217
+ text = self._fetch_bytes(url).decode("utf-8", errors="replace")
218
+ except TransientProviderFailureError:
219
+ self._license_cache = "unknown"
220
+ else:
221
+ self._license_cache = _detect_spdx_license(text)
222
+ return self._license_cache
223
+
224
+ def _fetch_bytes(self, url: str) -> bytes:
225
+ try:
226
+ return self._fetcher(url, self._timeout_seconds)
227
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
228
+ raise TransientProviderFailureError(
229
+ "failed to fetch Armory content", details={"url": url}
230
+ ) from error
231
+
232
+ def _entries(self) -> tuple[ArmoryPackageEntry, ...]:
233
+ import yaml
234
+
235
+ raw = self._fetch_bytes(self._manifest_url)
236
+ try:
237
+ document = yaml.safe_load(raw)
238
+ except yaml.YAMLError as error:
239
+ raise MissingPrerequisiteError("Armory manifest is not valid YAML") from error
240
+ return _parse_manifest(document)
241
+
242
+
243
+ __all__ = ["ArmoryCapabilitySource", "ArmoryPackageEntry"]
@@ -0,0 +1,371 @@
1
+ """Claude Code CLI harness adapter with versioned stream-json event normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import uuid
11
+ from collections.abc import Callable, Iterator
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Protocol, cast
16
+
17
+ from enginery.application.adapter_types import (
18
+ ADAPTER_API_VERSION,
19
+ AdapterAvailability,
20
+ AdapterCapability,
21
+ AdapterEventKind,
22
+ AdapterFingerprint,
23
+ AdapterStatus,
24
+ NormalizedAdapterEvent,
25
+ ProviderKind,
26
+ )
27
+ from enginery.application.work_ports import (
28
+ HarnessOutput,
29
+ HarnessResult,
30
+ HarnessSession,
31
+ HarnessTask,
32
+ )
33
+ from enginery.domain.artifact import RedactionClassification
34
+ from enginery.domain.errors import CancellationError, InvalidInputError, WorkerFailureError
35
+ from enginery.domain.ids import OperationId
36
+ from enginery.domain.node_attempt import ReconciliationResult
37
+ from enginery.ledger.artifact_store import ArtifactStore
38
+ from enginery.ledger.redaction import redact_credential_shaped_text
39
+
40
+ _CLAUDE_CODE_EVENT_SCHEMA_VERSION = 1
41
+ _KNOWN_EVENT_TYPES = frozenset({"system", "assistant", "user", "rate_limit_event", "result"})
42
+
43
+
44
+ class _ClaudeCodeProcess(Protocol):
45
+ pid: int
46
+ returncode: int | None
47
+
48
+ def communicate(self) -> tuple[str, str | None]: ...
49
+
50
+ def poll(self) -> int | None: ...
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class ClaudeCodeAdapterConfig:
55
+ """Opaque Claude Code CLI configuration without credential material.
56
+
57
+ ``model`` is an optional override; when blank, no ``--model`` flag is
58
+ passed and Claude Code resolves its own configured default. The
59
+ actually-selected model is read back from harness output, never assumed.
60
+ """
61
+
62
+ credential_reference: str
63
+ executable: str = "claude"
64
+ model: str = ""
65
+ event_schema_version: int = _CLAUDE_CODE_EVENT_SCHEMA_VERSION
66
+
67
+ def __post_init__(self) -> None:
68
+ if not self.credential_reference.strip():
69
+ raise InvalidInputError("Claude Code credential reference must be non-blank")
70
+ if not self.executable.strip():
71
+ raise InvalidInputError("Claude Code executable must be non-blank")
72
+ if self.event_schema_version != _CLAUDE_CODE_EVENT_SCHEMA_VERSION:
73
+ raise InvalidInputError("Claude Code event schema version is unsupported")
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class ClaudeCodeHarness:
78
+ """Run Claude Code headless and retain only redacted output artifacts.
79
+
80
+ Every launched command is scoped with ``--strict-mcp-config`` so a Stage 1
81
+ worker task never inherits the operator's ambient MCP servers. ``--bare``
82
+ is deliberately not used: it forces API-key-only authentication and
83
+ rejects the OAuth/keychain credentials most operators already have
84
+ configured via ``claude login``.
85
+ """
86
+
87
+ config: ClaudeCodeAdapterConfig
88
+ artifact_store: ArtifactStore
89
+ command_runner: Callable[[tuple[str, ...]], subprocess.CompletedProcess[str]] = field(
90
+ default=lambda arguments: subprocess.run(
91
+ arguments, check=False, capture_output=True, text=True
92
+ )
93
+ )
94
+ process_factory: Callable[[tuple[str, ...], Path], _ClaudeCodeProcess] = field(
95
+ default=lambda arguments, workspace: subprocess.Popen(
96
+ arguments,
97
+ cwd=workspace,
98
+ stdout=subprocess.PIPE,
99
+ stderr=subprocess.STDOUT,
100
+ stdin=subprocess.DEVNULL,
101
+ text=True,
102
+ start_new_session=True,
103
+ )
104
+ )
105
+ terminator: Callable[[int], None] = field(default=lambda pid: os.killpg(pid, signal.SIGTERM))
106
+ _sessions: dict[str, HarnessSession] = field(default_factory=dict, init=False)
107
+ _processes: dict[str, _ClaudeCodeProcess] = field(default_factory=dict, init=False)
108
+ _collected: dict[str, tuple[tuple[NormalizedAdapterEvent, ...], HarnessResult]] = field(
109
+ default_factory=dict, init=False
110
+ )
111
+ _outcomes: dict[str, ReconciliationResult] = field(default_factory=dict, init=False)
112
+ _cancelled: set[str] = field(default_factory=set, init=False)
113
+
114
+ def probe(self) -> AdapterStatus:
115
+ try:
116
+ completed = self.command_runner((self.config.executable, "--version"))
117
+ except OSError:
118
+ return AdapterStatus(
119
+ kind=ProviderKind.HARNESS,
120
+ availability=AdapterAvailability.UNAVAILABLE,
121
+ fingerprint=None,
122
+ detail="Claude Code CLI is unavailable",
123
+ )
124
+ version = completed.stdout.strip()
125
+ if completed.returncode != 0 or "Claude Code" not in version:
126
+ return AdapterStatus(
127
+ kind=ProviderKind.HARNESS,
128
+ availability=AdapterAvailability.MISCONFIGURED,
129
+ fingerprint=None,
130
+ detail="Claude Code CLI did not report a compatible version",
131
+ )
132
+ return AdapterStatus(
133
+ kind=ProviderKind.HARNESS,
134
+ availability=AdapterAvailability.AVAILABLE,
135
+ fingerprint=AdapterFingerprint(
136
+ provider_id="claude-code-harness",
137
+ provider_version=(
138
+ f"{version};stream-json-schema={self.config.event_schema_version}"
139
+ ),
140
+ api_version=ADAPTER_API_VERSION,
141
+ capabilities=(
142
+ AdapterCapability("stream_json_events", self.config.event_schema_version),
143
+ AdapterCapability("cancellation", 1),
144
+ AdapterCapability("redacted_artifacts", 1),
145
+ AdapterCapability("structured_output", 1),
146
+ ),
147
+ ),
148
+ detail="Claude Code harness is available",
149
+ )
150
+
151
+ def start(self, task: HarnessTask) -> HarnessSession:
152
+ session = HarnessSession(
153
+ session_id=f"claude-code-{task.operation_id}", operation_id=task.operation_id
154
+ )
155
+ if session.session_id in self._sessions:
156
+ raise WorkerFailureError("Claude Code harness session already exists")
157
+ process = self.process_factory(self.command_for(task), task.workspace_path)
158
+ self._sessions[session.session_id] = session
159
+ self._processes[session.session_id] = process
160
+ self._outcomes[str(task.operation_id)] = ReconciliationResult.FOUND_MATCHING
161
+ return session
162
+
163
+ def events(self, session: HarnessSession) -> Iterator[NormalizedAdapterEvent]:
164
+ return iter(self._collect(session)[0])
165
+
166
+ def result(self, session: HarnessSession) -> HarnessResult:
167
+ return self._collect(session)[1]
168
+
169
+ def cancel(self, session: HarnessSession, *, operation_id: OperationId) -> ReconciliationResult:
170
+ self._require_session(session)
171
+ process = self._processes[session.session_id]
172
+ if process.poll() is None:
173
+ self.terminator(process.pid)
174
+ self._cancelled.add(session.session_id)
175
+ self._outcomes[str(operation_id)] = ReconciliationResult.FOUND_MATCHING
176
+ return ReconciliationResult.FOUND_MATCHING
177
+
178
+ def reconcile(self, *, operation_id: OperationId) -> ReconciliationResult:
179
+ return self._outcomes.get(str(operation_id), ReconciliationResult.NOT_FOUND)
180
+
181
+ def _collect(
182
+ self, session: HarnessSession
183
+ ) -> tuple[tuple[NormalizedAdapterEvent, ...], HarnessResult]:
184
+ self._require_session(session)
185
+ cached = self._collected.get(session.session_id)
186
+ if cached is not None:
187
+ return cached
188
+ process = self._processes[session.session_id]
189
+ output, _ = process.communicate()
190
+ redacted = redact_credential_shaped_text(output)
191
+ digest = self.artifact_store.publish_bytes(redacted.encode(), media_type="application/json")
192
+ events, terminal_status = _normalize_events(redacted, session.operation_id)
193
+ status = "cancelled" if session.session_id in self._cancelled else terminal_status
194
+ result = HarnessResult(
195
+ session_id=session.session_id,
196
+ terminal_status=status,
197
+ outputs=(HarnessOutput(digest=digest, redaction=RedactionClassification.SENSITIVE),),
198
+ )
199
+ collected = (events, result)
200
+ self._collected[session.session_id] = collected
201
+ return collected
202
+
203
+ def command_for(self, task: HarnessTask) -> tuple[str, ...]:
204
+ arguments: list[str] = [
205
+ self.config.executable,
206
+ "-p",
207
+ _render_task(task),
208
+ "--output-format",
209
+ "stream-json",
210
+ "--verbose",
211
+ "--input-format",
212
+ "text",
213
+ "--no-session-persistence",
214
+ "--session-id",
215
+ str(uuid.uuid5(uuid.NAMESPACE_URL, str(task.operation_id))),
216
+ "--strict-mcp-config",
217
+ "--permission-mode",
218
+ "bypassPermissions",
219
+ ]
220
+ if self.config.model:
221
+ arguments += ["--model", self.config.model]
222
+ return tuple(arguments)
223
+
224
+ def _require_session(self, session: HarnessSession) -> None:
225
+ if self._sessions.get(session.session_id) != session:
226
+ raise CancellationError("unknown Claude Code harness session")
227
+
228
+ def supervised_command(self, task: HarnessTask, *, result_path: Path) -> tuple[str, ...]:
229
+ """Return a worker command whose parent is the coordinator supervisor.
230
+
231
+ Reuses the existing generic supervised-worker entrypoint: it only
232
+ runs the given command and redacts/records its exit, independent of
233
+ which harness CLI is being wrapped.
234
+ """
235
+ if not result_path.is_absolute():
236
+ raise InvalidInputError("Claude Code worker result path must be absolute")
237
+ return (
238
+ sys.executable,
239
+ "-m",
240
+ "enginery.engine.omp_worker",
241
+ "--operation-id",
242
+ str(task.operation_id),
243
+ "--output",
244
+ str(result_path),
245
+ "--",
246
+ *self.command_for(task),
247
+ )
248
+
249
+ def collect_supervised(
250
+ self, task: HarnessTask, *, result_path: Path
251
+ ) -> tuple[tuple[NormalizedAdapterEvent, ...], HarnessResult]:
252
+ """Read a supervised worker handoff after its process exit is observed."""
253
+ try:
254
+ payload = json.loads(result_path.read_text(encoding="utf-8"))
255
+ except (OSError, json.JSONDecodeError) as error:
256
+ raise WorkerFailureError(
257
+ "Claude Code supervised worker did not retain a valid result"
258
+ ) from error
259
+ if not isinstance(payload, dict) or set(payload) != {
260
+ "operation_id",
261
+ "output",
262
+ "schema_version",
263
+ "terminal_status",
264
+ }:
265
+ raise WorkerFailureError("Claude Code supervised worker result has an invalid shape")
266
+ operation_id = payload["operation_id"]
267
+ output = payload["output"]
268
+ schema_version = payload["schema_version"]
269
+ terminal_status = payload["terminal_status"]
270
+ if (
271
+ not isinstance(operation_id, str)
272
+ or not isinstance(output, str)
273
+ or not isinstance(schema_version, int)
274
+ or not isinstance(terminal_status, str)
275
+ ):
276
+ raise WorkerFailureError("Claude Code supervised worker result has invalid field types")
277
+ if schema_version != 1:
278
+ raise WorkerFailureError(
279
+ "Claude Code supervised worker result schema version is unsupported"
280
+ )
281
+ if operation_id != str(task.operation_id):
282
+ raise WorkerFailureError(
283
+ "Claude Code supervised worker result operation does not match task"
284
+ )
285
+ digest = self.artifact_store.publish_bytes(
286
+ output.encode("utf-8"), media_type="application/json"
287
+ )
288
+ events = (
289
+ _normalize_events(output, task.operation_id)[0]
290
+ if terminal_status == "succeeded"
291
+ else ()
292
+ )
293
+ return (
294
+ events,
295
+ HarnessResult(
296
+ session_id=f"claude-code-{task.operation_id}",
297
+ terminal_status=terminal_status,
298
+ outputs=(
299
+ HarnessOutput(digest=digest, redaction=RedactionClassification.SENSITIVE),
300
+ ),
301
+ ),
302
+ )
303
+
304
+
305
+ def _render_task(task: HarnessTask) -> str:
306
+ sections = (
307
+ ("Objective", (task.objective,)),
308
+ ("Acceptance criteria", task.acceptance_criteria),
309
+ ("Constraints", task.constraints),
310
+ ("Permitted capabilities", task.permitted_capabilities),
311
+ ("Evidence requirements", task.evidence_requirements),
312
+ )
313
+ return "\n\n".join(
314
+ f"{heading}:\n" + "\n".join(f"- {value}" for value in values)
315
+ for heading, values in sections
316
+ )
317
+
318
+
319
+ def _normalize_events(
320
+ output: str, operation_id: OperationId
321
+ ) -> tuple[tuple[NormalizedAdapterEvent, ...], str]:
322
+ events: list[NormalizedAdapterEvent] = []
323
+ terminal_status: str | None = None
324
+ for line in output.splitlines():
325
+ if not line.strip():
326
+ continue
327
+ try:
328
+ payload = cast(dict[str, object], json.loads(line))
329
+ except json.JSONDecodeError as error:
330
+ raise WorkerFailureError("Claude Code emitted malformed JSON event") from error
331
+ event_type = payload.get("type")
332
+ if not isinstance(event_type, str) or event_type not in _KNOWN_EVENT_TYPES:
333
+ raise WorkerFailureError("Claude Code emitted an unsupported JSON event")
334
+ attributes = {"event_schema_version": str(_CLAUDE_CODE_EVENT_SCHEMA_VERSION)}
335
+ if event_type == "system" and payload.get("subtype") == "init":
336
+ kind = AdapterEventKind.STARTED
337
+ elif event_type == "result":
338
+ kind = AdapterEventKind.TERMINAL
339
+ terminal_status = "failed" if payload.get("is_error") else "succeeded"
340
+ elif event_type == "rate_limit_event":
341
+ kind = AdapterEventKind.DIAGNOSTIC
342
+ else:
343
+ kind = AdapterEventKind.PROGRESS
344
+ model = _assistant_model(payload)
345
+ if model is not None:
346
+ attributes["model"] = model
347
+ events.append(
348
+ NormalizedAdapterEvent(
349
+ kind=kind,
350
+ occurred_at=datetime.now(UTC),
351
+ operation_id=operation_id,
352
+ summary=f"Claude Code {event_type}",
353
+ attributes=attributes,
354
+ )
355
+ )
356
+ if terminal_status is None:
357
+ raise WorkerFailureError("Claude Code output ended without a result event")
358
+ return tuple(events), terminal_status
359
+
360
+
361
+ def _assistant_model(payload: dict[str, object]) -> str | None:
362
+ if payload.get("type") != "assistant":
363
+ return None
364
+ message = payload.get("message")
365
+ if not isinstance(message, dict):
366
+ return None
367
+ model = message.get("model")
368
+ return model if isinstance(model, str) and model.strip() else None
369
+
370
+
371
+ __all__ = ["ClaudeCodeAdapterConfig", "ClaudeCodeHarness"]